diff --git a/CLAUDE.md b/CLAUDE.md index a6e95b895..a6de1b821 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,6 +107,11 @@ Use `pnpm w:` shortcuts: - **Do not** prefix your branch with your name or initials - **NEVER commit, push, or pull** - these actions are always manual +### File Search Scope + +- **NEVER search outside the current worktree** - all searches should stay within the current working directory +- The working directory is the root of the monorepo; do not traverse to parent directories or other projects + ### Code Quality Standards - Run type checking with `pnpm typecheck` or `pnpm w:app typecheck` after making changes diff --git a/apps/api/package.json b/apps/api/package.json index 35b2e898f..e35fc53bd 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -8,6 +8,7 @@ "build": "next build", "build:prod": "cd ../../services/db && pnpm run migrate && cd ../../apps/api && next build", "dev": "next dev -p 3300", + "dev:e2e": "next dev -p 4300", "format:check": "prettier --log-level=warn --check \"**/*.{ts,tsx}\" \"!.next\"", "start": "next start", "typecheck": "tsgo --noEmit" diff --git a/apps/app/package.json b/apps/app/package.json index fafc8db0d..33a1a9b4c 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -8,6 +8,7 @@ "build": "next build", "check:i18n": "tsx scripts/check-missing-intl-keys.ts", "dev": "next dev -p 3100", + "dev:e2e": "next dev -p 4100", "format:check": "prettier --log-level=warn --check \"**/*.{ts,tsx}\" \"!.next\"", "start": "next start -p 3100", "typecheck": "tsgo --noEmit" diff --git a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/edit/page.tsx b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/edit/page.tsx index dc62ec6e9..fd195f4b2 100644 --- a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/edit/page.tsx +++ b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/edit/page.tsx @@ -39,7 +39,7 @@ const EditDecisionPage = async ({
diff --git a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/proposal/[profileId]/page.tsx b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/proposal/[profileId]/page.tsx index e889f9a3f..04e95fd4c 100644 --- a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/proposal/[profileId]/page.tsx +++ b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/proposal/[profileId]/page.tsx @@ -7,6 +7,8 @@ import { Suspense } from 'react'; import ErrorBoundary from '@/components/ErrorBoundary'; import { ProposalView } from '@/components/decisions/ProposalView'; +const LEGACY_ORG_SLUGS = ['people-powered', 'cowop', 'one-project']; + function ProposalViewPageContent({ profileId, slug, @@ -14,15 +16,22 @@ function ProposalViewPageContent({ profileId: string; slug: string; }) { - const [proposal] = trpc.decision.getProposal.useSuspenseQuery({ - profileId, - }); + const [[proposal, decisionProfile]] = trpc.useSuspenseQueries((t) => [ + t.decision.getProposal({ profileId }), + t.decision.getDecisionBySlug({ slug }), + ]); if (!proposal) { notFound(); } - const backHref = `/decisions/${slug}`; + const ownerSlug = decisionProfile?.processInstance?.owner?.slug; + const instanceId = decisionProfile?.processInstance?.id; + + const backHref = + ownerSlug && LEGACY_ORG_SLUGS.includes(ownerSlug) && instanceId + ? `/profile/${ownerSlug}/decisions/${instanceId}/` + : `/decisions/${slug}`; return ; } diff --git a/apps/app/src/app/[locale]/(no-header)/profile/[slug]/decisions/[id]/proposal/[profileId]/page.tsx b/apps/app/src/app/[locale]/(no-header)/profile/[slug]/decisions/[id]/proposal/[profileId]/page.tsx index 3307b6e5c..439b958d4 100644 --- a/apps/app/src/app/[locale]/(no-header)/profile/[slug]/decisions/[id]/proposal/[profileId]/page.tsx +++ b/apps/app/src/app/[locale]/(no-header)/profile/[slug]/decisions/[id]/proposal/[profileId]/page.tsx @@ -9,10 +9,12 @@ import { ProposalView } from '@/components/decisions/ProposalView'; function ProposalViewPageContent({ profileId, - decisionSlug, + orgSlug, + instanceId, }: { profileId: string; - decisionSlug: string; + orgSlug: string; + instanceId: string; }) { const [proposal] = trpc.decision.getProposal.useSuspenseQuery({ profileId, @@ -22,7 +24,7 @@ function ProposalViewPageContent({ notFound(); } - const backHref = `/decisions/${decisionSlug}`; + const backHref = `/profile/${orgSlug}/decisions/${instanceId}/`; return ; } @@ -74,15 +76,20 @@ function ProposalViewPageSkeleton() { } const ProposalViewPage = () => { - const { profileId, slug } = useParams<{ + const { profileId, slug, id } = useParams<{ profileId: string; slug: string; + id: string; }>(); return ( }> - + ); diff --git a/apps/app/src/components/ActiveDecisionsNotifications/index.tsx b/apps/app/src/components/ActiveDecisionsNotifications/index.tsx index f6194a7ba..c14735a97 100644 --- a/apps/app/src/components/ActiveDecisionsNotifications/index.tsx +++ b/apps/app/src/components/ActiveDecisionsNotifications/index.tsx @@ -57,7 +57,7 @@ const ActiveDecisionsNotificationsSuspense = () => { setNavigatingId(decision.id)} > {isNavigating ? : 'Participate'} diff --git a/apps/app/src/components/LoginPanel.tsx b/apps/app/src/components/LoginPanel.tsx index 86bb140b2..6f7133442 100644 --- a/apps/app/src/components/LoginPanel.tsx +++ b/apps/app/src/components/LoginPanel.tsx @@ -16,7 +16,7 @@ import { useSearchParams } from 'next/navigation'; import React, { useCallback } from 'react'; import { z } from 'zod'; import { create } from 'zustand'; -import GoogleIcon from '~icons/logos/google-icon.jsx'; +import GoogleIcon from '~icons/logos/google-icon'; import { CommonLogo } from './CommonLogo'; diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderContent.tsx index d91b8570d..42d422536 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderContent.tsx @@ -1,17 +1,26 @@ 'use client'; +import { useUser } from '@/utils/UserProvider'; + import { type SectionProps, getContentComponent } from './contentRegistry'; import { type NavigationConfig } from './navigationConfig'; import { useProcessNavigation } from './useProcessNavigation'; export function ProcessBuilderContent({ - decisionId, + decisionProfileId, decisionName, navigationConfig, }: SectionProps & { navigationConfig?: NavigationConfig }) { const { currentStep, currentSection } = useProcessNavigation(navigationConfig); + const access = useUser(); + const isAdmin = access.getPermissionsForProfile(decisionProfileId).admin; + + if (!isAdmin) { + throw new Error('UNAUTHORIZED'); + } + const ContentComponent = getContentComponent( currentStep?.id, currentSection?.id, @@ -22,6 +31,9 @@ export function ProcessBuilderContent({ } return ( - + ); } diff --git a/apps/app/src/components/decisions/ProcessBuilder/contentRegistry.tsx b/apps/app/src/components/decisions/ProcessBuilder/contentRegistry.tsx index 1fceb64ec..6a827ac15 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/contentRegistry.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/contentRegistry.tsx @@ -15,7 +15,7 @@ import FormBuilderSection from './stepContent/template/FormBuilderSection'; // Props that all section components receive export interface SectionProps { - decisionId: string; + decisionProfileId: string; decisionName: string; } diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/members/MembersSection.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/members/MembersSection.tsx index 6fb3e7d6a..45f5fda95 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/members/MembersSection.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/members/MembersSection.tsx @@ -2,11 +2,11 @@ import { ProfileUsersAccess } from '@/components/decisions/ProfileUsersAccess'; import type { SectionProps } from '../../contentRegistry'; -export default function MembersSection({ decisionId }: SectionProps) { +export default function MembersSection({ decisionProfileId }: SectionProps) { return (
- +
); diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/members/RolesSection.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/members/RolesSection.tsx index 277df25fb..e7b0ae9b6 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/members/RolesSection.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/members/RolesSection.tsx @@ -1,14 +1,14 @@ import type { SectionProps } from '../../contentRegistry'; export default function RolesSection({ - decisionId, + decisionProfileId, decisionName, }: SectionProps) { return (

Roles & Permissions

Decision: {decisionName}

-

ID: {decisionId}

+

ID: {decisionProfileId}

{/* TODO: Implement roles and permissions configuration */}
); diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/overview/OverviewSection.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/overview/OverviewSection.tsx index aaa722bd8..dc4a31656 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/overview/OverviewSection.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/overview/OverviewSection.tsx @@ -1,14 +1,14 @@ import type { SectionProps } from '../../contentRegistry'; export default function OverviewSection({ - decisionId, + decisionProfileId, decisionName, }: SectionProps) { return (

Overview

Decision: {decisionName}

-

ID: {decisionId}

+

ID: {decisionProfileId}

Cupidatat pariatur irure et labore quis aliquip ullamco. Laboris cupidatat amet duis deserunt reprehenderit. Voluptate duis qui Lorem diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/overview/PhasesSection.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/overview/PhasesSection.tsx index e3a7f6d71..dd78584fd 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/overview/PhasesSection.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/overview/PhasesSection.tsx @@ -1,14 +1,14 @@ import type { SectionProps } from '../../contentRegistry'; export default function PhasesSection({ - decisionId, + decisionProfileId, decisionName, }: SectionProps) { return (

Phases

Decision: {decisionName}

-

ID: {decisionId}

+

ID: {decisionProfileId}

{/* TODO: Implement phases configuration */}
); diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/overview/ProposalCategoriesSection.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/overview/ProposalCategoriesSection.tsx index ad1b0748c..270e6f11d 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/overview/ProposalCategoriesSection.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/overview/ProposalCategoriesSection.tsx @@ -1,14 +1,14 @@ import type { SectionProps } from '../../contentRegistry'; export default function ProposalCategoriesSection({ - decisionId, + decisionProfileId, decisionName, }: SectionProps) { return (

Proposal Categories

Decision: {decisionName}

-

ID: {decisionId}

+

ID: {decisionProfileId}

{/* TODO: Implement proposal categories configuration */}
); diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/overview/VotingSection.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/overview/VotingSection.tsx index 045f05232..cf32477a1 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/overview/VotingSection.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/overview/VotingSection.tsx @@ -1,14 +1,14 @@ import type { SectionProps } from '../../contentRegistry'; export default function VotingSection({ - decisionId, + decisionProfileId, decisionName, }: SectionProps) { return (

Voting

Decision: {decisionName}

-

ID: {decisionId}

+

ID: {decisionProfileId}

{/* TODO: Implement voting configuration */}
); diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/CriteriaSection.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/CriteriaSection.tsx index 67905eef1..fc1fcd90c 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/CriteriaSection.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/CriteriaSection.tsx @@ -1,14 +1,14 @@ import type { SectionProps } from '../../contentRegistry'; export default function CriteriaSection({ - decisionId, + decisionProfileId, decisionName, }: SectionProps) { return (

Criteria

Decision: {decisionName}

-

ID: {decisionId}

+

ID: {decisionProfileId}

{/* TODO: Implement rubric criteria configuration */}
); diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/SettingsSection.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/SettingsSection.tsx index fa85daf84..70421ed63 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/SettingsSection.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/SettingsSection.tsx @@ -1,14 +1,14 @@ import type { SectionProps } from '../../contentRegistry'; export default function SettingsSection({ - decisionId, + decisionProfileId, decisionName, }: SectionProps) { return (

Settings

Decision: {decisionName}

-

ID: {decisionId}

+

ID: {decisionProfileId}

{/* TODO: Implement rubric settings configuration */}
); diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/FormBuilderSection.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/FormBuilderSection.tsx index 5a7c8e668..9c70ebb2c 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/FormBuilderSection.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/FormBuilderSection.tsx @@ -1,14 +1,14 @@ import type { SectionProps } from '../../contentRegistry'; export default function FormBuilderSection({ - decisionId, + decisionProfileId, decisionName, }: SectionProps) { return (

Form Builder

Decision: {decisionName}

-

ID: {decisionId}

+

ID: {decisionProfileId}

{/* TODO: Implement form builder with custom sidebar */}
); diff --git a/apps/app/src/components/screens/PageError/index.tsx b/apps/app/src/components/screens/PageError/index.tsx index 872729e68..6d0e52903 100644 --- a/apps/app/src/components/screens/PageError/index.tsx +++ b/apps/app/src/components/screens/PageError/index.tsx @@ -1,36 +1,60 @@ 'use client'; import { ClientOnly } from '@/utils/ClientOnly'; +import { match } from '@op/core'; import { Button } from '@op/ui/Button'; import { Header2 } from '@op/ui/Header'; -import { useEffect } from 'react'; + +import { useTranslations } from '@/lib/i18n/routing'; export interface ErrorProps { error: Error & { digest?: string }; - reset: () => void; } export default function PageError({ error }: ErrorProps) { - useEffect(() => { - console.error('Application error:', error); - }, [error]); + const t = useTranslations(); + + const errorData = match(error.message, { + UNAUTHORIZED: () => ({ + code: 403, + description: ( +

+ {t('You do not have permission to view this page')} +

+ ), + actions: ( + + ), + }), + _: () => ({ + code: 500, + description: ( +

+ {t("Something went wrong on our end. We're working to fix it.")} +
+ {t('Please try again in a moment')} +

+ ), + actions: ( + + ), + }), + }); return (
- 500 + {errorData.code} -

- Something went wrong on our end. We're working to fix it. -
- Please try again in a moment -

+ {errorData.description}
- + {errorData.actions}
); diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index 2c4e5cfb4..d1c45be3b 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -458,5 +458,9 @@ "New process": "নতুন প্রক্রিয়া", "Launch": "চালু করুন", "Process": "প্রক্রিয়া", - "Section navigation": "বিভাগ নেভিগেশন" + "Section navigation": "বিভাগ নেভিগেশন", + "You do not have permission to view this page": "আপনার এই পৃষ্ঠা দেখার অনুমতি নেই", + "Go back": "ফিরে যান", + "Something went wrong on our end. We're working to fix it.": "আমাদের দিক থেকে কিছু ভুল হয়েছে। আমরা এটি ঠিক করার জন্য কাজ করছি।", + "Please try again in a moment": "অনুগ্রহ করে কিছুক্ষণ পরে আবার চেষ্টা করুন" } diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index 09d53f3cf..772296da2 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -458,5 +458,9 @@ "New process": "New process", "Launch": "Launch", "Process": "Process", - "Section navigation": "Section navigation" + "Section navigation": "Section navigation", + "You do not have permission to view this page": "You do not have permission to view this page", + "Go back": "Go back", + "Something went wrong on our end. We're working to fix it.": "Something went wrong on our end. We're working to fix it.", + "Please try again in a moment": "Please try again in a moment" } diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index 4d9df2a1e..e87bd18ce 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -457,5 +457,9 @@ "New process": "Nuevo proceso", "Launch": "Lanzar", "Process": "Proceso", - "Section navigation": "Navegación de secciones" + "Section navigation": "Navegación de secciones", + "You do not have permission to view this page": "No tienes permiso para ver esta página", + "Go back": "Volver", + "Something went wrong on our end. We're working to fix it.": "Algo salió mal de nuestro lado. Estamos trabajando para solucionarlo.", + "Please try again in a moment": "Por favor, intenta de nuevo en un momento" } diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index b82af07d2..bbc180233 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -458,5 +458,9 @@ "New process": "Nouveau processus", "Launch": "Lancer", "Process": "Processus", - "Section navigation": "Navigation des sections" + "Section navigation": "Navigation des sections", + "You do not have permission to view this page": "Vous n'avez pas la permission de voir cette page", + "Go back": "Retourner", + "Something went wrong on our end. We're working to fix it.": "Une erreur s'est produite de notre côté. Nous travaillons à la corriger.", + "Please try again in a moment": "Veuillez réessayer dans un instant" } diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index 75de2491b..c51af651b 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -458,5 +458,9 @@ "New process": "Novo processo", "Launch": "Lançar", "Process": "Processo", - "Section navigation": "Navegação de seções" + "Section navigation": "Navegação de seções", + "You do not have permission to view this page": "Você não tem permissão para ver esta página", + "Go back": "Voltar", + "Something went wrong on our end. We're working to fix it.": "Algo deu errado do nosso lado. Estamos trabalhando para corrigir.", + "Please try again in a moment": "Por favor, tente novamente em instantes" } diff --git a/package.json b/package.json index c41b66c71..96a38b2c1 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "deps:override": "tsx scripts/depCheck.ts --multiuse && pnpm prettier --write **/package.json --write", "deps:viz": "node ./scripts/visualize-deps.mjs", "dev": "turbo dev", - "dev:e2e": "cross-env NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:56321 DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:56322/postgres S3_ASSET_ROOT=http://127.0.0.1:56321/storage/v1/object/public/assets turbo dev", + "dev:e2e": "cross-env NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:56321 DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:56322/postgres S3_ASSET_ROOT=http://127.0.0.1:56321/storage/v1/object/public/assets turbo dev:e2e", "dev:test": "dotenv -e .env.test -o -- turbo dev", "e2e": "pnpm -C ./tests/e2e e2e", "e2e:ui": "pnpm -C ./tests/e2e e2e:ui", diff --git a/packages/styles/package.json b/packages/styles/package.json index 9cef11155..7c7fe7e6e 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -13,6 +13,7 @@ "scripts": { "build": "tailwindcss -i ./shared-styles.css -o ./dist/styles.css --minify", "dev": "tailwindcss -i ./shared-styles.css -o ./dist/styles.css --watch", + "dev:e2e": "tailwindcss -i ./shared-styles.css -o ./dist/styles.css --watch", "typecheck": "tsgo --noEmit" }, "devDependencies": { diff --git a/packages/ui/src/components/SocialLinks.tsx b/packages/ui/src/components/SocialLinks.tsx index dddc51741..2f49b26ec 100644 --- a/packages/ui/src/components/SocialLinks.tsx +++ b/packages/ui/src/components/SocialLinks.tsx @@ -1,6 +1,6 @@ -import GithubIcon from '~icons/carbon/logo-github.jsx'; -import LinkedinIcon from '~icons/carbon/logo-linkedin.jsx'; -import TwitterIcon from '~icons/carbon/logo-x.jsx'; +import GithubIcon from '~icons/carbon/logo-github'; +import LinkedinIcon from '~icons/carbon/logo-linkedin'; +import TwitterIcon from '~icons/carbon/logo-x'; import { cn } from '../lib/utils'; diff --git a/services/api/package.json b/services/api/package.json index 04bde2d01..b28318099 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -14,13 +14,13 @@ }, "scripts": { "format:check": "prettier --log-level=warn --check \"**/*.{ts,tsx}\"", - "test": "vitest", + "test": "vitest run", "test:check-supabase": "tsx src/test/check-supabase.ts", "test:db:reset": "pnpm test:supabase:reset && pnpm test:migrate", "test:supabase:start": "tsx src/test/supabase-test.ts start", "test:supabase:status": "tsx src/test/supabase-test.ts status", "test:supabase:stop": "tsx src/test/supabase-test.ts stop", - "test:watch": "vitest --watch", + "test:watch": "vitest", "typecheck": "tsgo --noEmit" }, "dependencies": { diff --git a/services/api/src/encoders/decision.ts b/services/api/src/encoders/decision.ts index d10d76385..867babb17 100644 --- a/services/api/src/encoders/decision.ts +++ b/services/api/src/encoders/decision.ts @@ -571,6 +571,27 @@ export const updateProposalInputSchema = createProposalInputSchema .partial() .extend({ visibility: z.enum(Visibility).optional(), + /** + * Evaluation status for the proposal. This update endpoint handles evaluation + * status changes (shortlisted, approved, rejected, etc.) - not submission state. + * + * NOTE: To be looked at again - draft/submitted represent the submission lifecycle + * (whether a proposal has been finalized by its author), while the statuses below + * represent how the proposal has been evaluated by reviewers/admins. These are + * conceptually different and may warrant separate fields in the future. + * + * Use submitProposal endpoint for draft→submitted transition. + */ + status: z + .enum([ + ProposalStatus.SHORTLISTED, + ProposalStatus.UNDER_REVIEW, + ProposalStatus.APPROVED, + ProposalStatus.REJECTED, + ProposalStatus.DUPLICATE, + ProposalStatus.SELECTED, + ]) + .optional(), }); export const submitDecisionInputSchema = z.object({ diff --git a/services/api/src/routers/decision/proposals/create.ts b/services/api/src/routers/decision/proposals/create.ts index 9dface03f..613c7e556 100644 --- a/services/api/src/routers/decision/proposals/create.ts +++ b/services/api/src/routers/decision/proposals/create.ts @@ -7,16 +7,16 @@ import { import { TRPCError } from '@trpc/server'; import { - legacyCreateProposalInputSchema, - legacyProposalEncoder, -} from '../../../encoders/legacyDecision'; + createProposalInputSchema, + proposalEncoder, +} from '../../../encoders/decision'; import { commonAuthedProcedure, router } from '../../../trpcFactory'; export const createProposalRouter = router({ /** Creates a new proposal in draft status. Use submitProposal to transition to submitted. */ createProposal: commonAuthedProcedure() - .input(legacyCreateProposalInputSchema) - .output(legacyProposalEncoder) + .input(createProposalInputSchema) + .output(proposalEncoder) .mutation(async ({ ctx, input }) => { const { user, logger } = ctx; @@ -26,7 +26,7 @@ export const createProposalRouter = router({ authUserId: user.id, }); - return legacyProposalEncoder.parse(proposal); + return proposalEncoder.parse(proposal); } catch (error: unknown) { logger.error('Failed to create proposal', { userId: user.id, diff --git a/services/api/src/routers/decision/proposals/get.ts b/services/api/src/routers/decision/proposals/get.ts index 9e1d3a2ea..681cdb3d3 100644 --- a/services/api/src/routers/decision/proposals/get.ts +++ b/services/api/src/routers/decision/proposals/get.ts @@ -1,5 +1,6 @@ import { cache } from '@op/cache'; import { getPermissionsOnProposal, getProposal } from '@op/common'; +import { ProposalStatus } from '@op/db/schema'; import { logger } from '@op/logging'; import { waitUntil } from '@vercel/functions'; import { z } from 'zod'; @@ -30,7 +31,7 @@ export const getProposalRouter = router({ user, }), options: { - skipMemCache: true, // We need these to be editable and then immediately accessible + skipCacheWrite: (result) => result.status === ProposalStatus.DRAFT, }, }); diff --git a/services/api/src/routers/decision/proposals/list.ts b/services/api/src/routers/decision/proposals/list.ts index e7ee42439..40c18ba9c 100644 --- a/services/api/src/routers/decision/proposals/list.ts +++ b/services/api/src/routers/decision/proposals/list.ts @@ -1,12 +1,14 @@ import { listProposals } from '@op/common'; -import { proposalListEncoder } from '../../../encoders/decision'; -import { legacyProposalFilterSchema } from '../../../encoders/legacyDecision'; +import { + proposalFilterSchema, + proposalListEncoder, +} from '../../../encoders/decision'; import { commonAuthedProcedure, router } from '../../../trpcFactory'; export const listProposalsRouter = router({ listProposals: commonAuthedProcedure() - .input(legacyProposalFilterSchema) + .input(proposalFilterSchema) .output(proposalListEncoder) .query(async ({ ctx, input }) => { const { user } = ctx; diff --git a/services/api/src/routers/decision/proposals/updateProposal.test.ts b/services/api/src/routers/decision/proposals/update.test.ts similarity index 61% rename from services/api/src/routers/decision/proposals/updateProposal.test.ts rename to services/api/src/routers/decision/proposals/update.test.ts index 94c70e9cf..60407d179 100644 --- a/services/api/src/routers/decision/proposals/updateProposal.test.ts +++ b/services/api/src/routers/decision/proposals/update.test.ts @@ -1,4 +1,4 @@ -import { Visibility } from '@op/db/schema'; +import { ProposalStatus, Visibility } from '@op/db/schema'; import { describe, expect, it } from 'vitest'; import { appRouter } from '../..'; @@ -280,3 +280,190 @@ describe.concurrent('updateProposal visibility', () => { expect(result.canManageProposals).toBe(true); }); }); + +describe.concurrent('updateProposal status', () => { + it('should allow admin to update proposal status to evaluation statuses', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + instanceCount: 1, + grantAccess: true, + }); + + const instance = setup.instances[0]; + if (!instance) { + throw new Error('No instance created'); + } + + const proposal = await testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Test Proposal', description: 'A test' }, + }); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + // Admin should be able to update status to SHORTLISTED + const result = await caller.decision.updateProposal({ + proposalId: proposal.id, + data: { status: ProposalStatus.SHORTLISTED }, + }); + + expect(result.status).toBe(ProposalStatus.SHORTLISTED); + }); + + it('should allow admin to update proposal status', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + instanceCount: 1, + grantAccess: true, + }); + + const instance = setup.instances[0]; + if (!instance) { + throw new Error('No instance created'); + } + + const proposal = await testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Test Proposal', description: 'A test' }, + }); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + const result = await caller.decision.updateProposal({ + proposalId: proposal.id, + data: { status: ProposalStatus.APPROVED }, + }); + expect(result.status).toBe(ProposalStatus.APPROVED); + }); + + it('should not allow non-admin to change proposal status', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + instanceCount: 1, + grantAccess: true, + }); + + const instance = setup.instances[0]; + if (!instance) { + throw new Error('No instance created'); + } + + const proposal = await testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Test Proposal', description: 'A test' }, + }); + + const memberUser = await testData.createMemberUser({ + organization: setup.organization, + instanceProfileIds: [instance.profileId], + }); + + const nonAdminCaller = await createAuthenticatedCaller(memberUser.email); + + // Non-admin should NOT be able to change status + await expect( + nonAdminCaller.decision.updateProposal({ + proposalId: proposal.id, + data: { status: ProposalStatus.SHORTLISTED }, + }), + ).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + + it('should reject invalid status values like draft or submitted', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + instanceCount: 1, + grantAccess: true, + }); + + const instance = setup.instances[0]; + if (!instance) { + throw new Error('No instance created'); + } + + const proposal = await testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Test Proposal', description: 'A test' }, + }); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + // Should reject draft status (use submitProposal endpoint instead) + await expect( + caller.decision.updateProposal({ + proposalId: proposal.id, + data: { status: ProposalStatus.DRAFT as never }, + }), + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + + // Should reject submitted status (use submitProposal endpoint instead) + await expect( + caller.decision.updateProposal({ + proposalId: proposal.id, + data: { status: ProposalStatus.SUBMITTED as never }, + }), + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + + it('should allow updating both status and visibility together', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + instanceCount: 1, + grantAccess: true, + }); + + const instance = setup.instances[0]; + if (!instance) { + throw new Error('No instance created'); + } + + const proposal = await testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Test Proposal', description: 'A test' }, + }); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + const result = await caller.decision.updateProposal({ + proposalId: proposal.id, + data: { + status: ProposalStatus.REJECTED, + visibility: Visibility.HIDDEN, + }, + }); + + expect(result.status).toBe(ProposalStatus.REJECTED); + expect(result.visibility).toBe(Visibility.HIDDEN); + }); +}); diff --git a/services/api/src/routers/decision/proposals/update.ts b/services/api/src/routers/decision/proposals/update.ts index bb030ab45..f8ba1305c 100644 --- a/services/api/src/routers/decision/proposals/update.ts +++ b/services/api/src/routers/decision/proposals/update.ts @@ -9,9 +9,9 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { - legacyProposalEncoder, - legacyUpdateProposalInputSchema, -} from '../../../encoders/legacyDecision'; + proposalEncoder, + updateProposalInputSchema, +} from '../../../encoders/decision'; import { commonAuthedProcedure, router } from '../../../trpcFactory'; export const updateProposalRouter = router({ @@ -21,10 +21,10 @@ export const updateProposalRouter = router({ .input( z.object({ proposalId: z.uuid(), - data: legacyUpdateProposalInputSchema, + data: updateProposalInputSchema, }), ) - .output(legacyProposalEncoder) + .output(proposalEncoder) .mutation(async ({ ctx, input }) => { const { user, logger } = ctx; const { proposalId } = input; @@ -41,7 +41,7 @@ export const updateProposalRouter = router({ params: [proposal.profileId], }); - return legacyProposalEncoder.parse(proposal); + return proposalEncoder.parse(proposal); } catch (error: unknown) { logger.error('Failed to update proposal', { userId: user.id, diff --git a/services/api/src/routers/organization/getOrganization.ts b/services/api/src/routers/organization/getOrganization.ts index ca09c545c..89cd74226 100644 --- a/services/api/src/routers/organization/getOrganization.ts +++ b/services/api/src/routers/organization/getOrganization.ts @@ -7,6 +7,7 @@ import { import { TRPCError } from '@trpc/server'; import { z } from 'zod'; +import { Profile } from '../../encoders'; import { organizationsTermsEncoder, organizationsWithProfileEncoder, @@ -43,9 +44,12 @@ export const getOrganizationRouter = router({ ...result, profile: { ...result.profile, - modules: result.profile.modules?.map((profileModule: any) => ({ - slug: profileModule.module.slug, - })), + // type assertion to be fixed with Drizzle RQB v2 + modules: (result.profile as Profile).modules?.map( + (profileModule: any) => ({ + slug: profileModule.module.slug, + }), + ), }, }; diff --git a/services/api/src/routers/platform/admin/listAllUsers.ts b/services/api/src/routers/platform/admin/listAllUsers.ts index 05ad7d704..84b086ee1 100644 --- a/services/api/src/routers/platform/admin/listAllUsers.ts +++ b/services/api/src/routers/platform/admin/listAllUsers.ts @@ -108,7 +108,7 @@ export const listAllUsersRouter = router({ .select({ value: count() }) .from(users) .where(searchCondition); - return result; + return result ?? { value: 0 }; }, options: { ttl: 1 * 60 * 1000, // 1 min diff --git a/services/api/src/test/globalSetup.ts b/services/api/src/test/globalSetup.ts index 29a841f98..129a6b3e1 100644 --- a/services/api/src/test/globalSetup.ts +++ b/services/api/src/test/globalSetup.ts @@ -148,4 +148,7 @@ export async function teardown() { .delete(schema.accessZones) .where(inArray(schema.accessZones.id, accessZoneIds)); console.log('✅ Deseeding completed'); + + // Close the database connection to allow the process to exit + await db.$client.end(); } diff --git a/services/api/vitest.config.ts b/services/api/vitest.config.ts index 7a6e7115b..bbbda09fd 100644 --- a/services/api/vitest.config.ts +++ b/services/api/vitest.config.ts @@ -23,9 +23,9 @@ export default defineConfig({ globals: true, globalSetup: ['./src/test/globalSetup.ts'], setupFiles: ['./src/test/setup.ts'], - testTimeout: 30000, - hookTimeout: 30000, - // Set actual process.env values - works in globalSetup and setupFiles + testTimeout: 30_000, + maxWorkers: '75%', + pool: 'threads', env: TEST_ENV, }, resolve: { diff --git a/services/cache/kv.ts b/services/cache/kv.ts index d6f62f12b..7967e1cad 100644 --- a/services/cache/kv.ts +++ b/services/cache/kv.ts @@ -52,30 +52,51 @@ const TypeMap = { decision: 'decision', }; +/** Allowed types for cache params - will be stringified for key generation */ +type CacheParam = string | number | boolean | undefined | null | string[]; +type CacheParams = CacheParam[]; + const getCacheKey = ( type: keyof typeof TypeMap, - appKey: string = 'common', - params: Array, + appKey: string | undefined, + params: CacheParams, ) => { + const resolvedAppKey = appKey ?? 'common'; const apiVersion = OPURLConfig('API').IS_PRODUCTION ? 'v1' : 'dev/v1'; const key = TypeMap[type]; - const [fullSlug, ...otherParams] = params; + // Stringify params for cache key - handles arrays, undefined, etc. + const stringParams = params + .flat() + .map((p) => (p === undefined || p === null ? '' : String(p))) + .filter(Boolean); + const [fullSlug, ...otherParams] = stringParams; // this matches the ability to disregard full paths so pages can be moved without a 404 const slug = fullSlug?.split('/').slice(-1)[0] ?? ''; - return `${apiVersion}/${appKey}/${key}/${slug}${ + return `${apiVersion}/${resolvedAppKey}/${key}/${slug}${ otherParams?.length ? `:${otherParams.join(':')}` : '' }`; }; // TODO: replace with something like an LRU cache -const memCache = new Map(); +const memCache = new Map(); const MEMCACHE_EXPIRE = 2 * 60 * 1000; -/* - * Caches values into a tiered structure of memcache, KV cache, and ultimately a call to the DB +/** + * Caches values into a tiered structure: memcache → Redis → fetch function. + * + * @param type - Cache key type from TypeMap + * @param appKey - Application key (defaults to 'common') + * @param params - Parameters used to build the cache key + * @param fetch - Function to call on cache miss + * @param options.skipMemCache - Skip in-memory cache layer + * @param options.storeNulls - Cache null results to avoid repeated DB lookups + * @param options.ttl - Time-to-live in milliseconds + * @param options.skipCacheWrite - Predicate to conditionally skip caching based on result. + * When returns true, the result is NOT stored in cache. + * Useful for skipping cache on draft/incomplete data. */ -export const cache = async ({ +export const cache = async ({ type, appKey, params = [], @@ -84,25 +105,25 @@ export const cache = async ({ }: { type: keyof typeof TypeMap; appKey?: string; - params?: any[]; - fetch: () => Promise; + params?: CacheParams; + fetch: () => Promise>; options?: { skipMemCache?: boolean; storeNulls?: boolean; ttl?: number; + skipCacheWrite?: (result: Awaited) => boolean; }; -}): Promise => { +}): Promise> => { const cacheKey = getCacheKey(type, appKey, params); const { ttl, skipMemCache = false, storeNulls = false } = options; // try memcache first - if (!skipMemCache && memCache.has(cacheKey)) { - const cachedVal = memCache.get(cacheKey); - + const cachedVal = !skipMemCache ? memCache.get(cacheKey) : undefined; + if (cachedVal) { const memCacheExpire = ttl ? ttl : MEMCACHE_EXPIRE; if (Date.now() - cachedVal.createdAt < memCacheExpire) { cacheMetrics.recordHit({ type: 'memory', keyType: type }); - return cachedVal.data; + return cachedVal.data as Awaited; } } @@ -113,28 +134,31 @@ export const cache = async ({ setTimeout(() => resolve(null), 300); }); - const data = (await Promise.race([get(cacheKey), timeout])) as T; + const data = (await Promise.race([get(cacheKey), timeout])) as Awaited; if (data) { cacheMetrics.recordHit({ type: 'kv', source: 'redis', keyType: type }); memCache.set(cacheKey, { createdAt: Date.now(), data }); - return data as T; + return data; } // finally retrieve the data from the DB const newData = await fetch(); cacheMetrics.recordMiss(type); - if (newData) { + + const shouldSkipCache = options.skipCacheWrite?.(newData) ?? false; + + if (newData && !shouldSkipCache) { memCache.set(cacheKey, { createdAt: Date.now(), data: newData }); // don't cache if we couldn't find the record (?) // TTL in redis is in seconds waitUntil(set(cacheKey, newData, ttl ? ttl / 1000 : 72 * 60 * 60)); // 72h default cache - } else if (storeNulls) { + } else if (storeNulls && !shouldSkipCache) { // This allows us to store negative values in the memcache to improve rejections as well (and avoid DB calls for repeated rejections) memCache.set(cacheKey, { createdAt: Date.now(), data: null }); } - return newData as T; + return newData; }; export const invalidate = async ({ @@ -145,8 +169,8 @@ export const invalidate = async ({ }: { type: keyof typeof TypeMap; appKey?: string; - params: any[]; - data?: any; // Updates the data rather than invalidating it + params: CacheParams; + data?: unknown; }) => { const cacheKey = getCacheKey(type, appKey, params); @@ -167,7 +191,7 @@ export const invalidateMultiple = async ({ }: { type: keyof typeof TypeMap; appKey?: string; - paramsList: any[][]; + paramsList: CacheParams[]; }) => { await Promise.all( paramsList.map((params) => @@ -203,7 +227,7 @@ export const get = async (key: string) => { // const DEFAULT_TTL = 3600 * 24 * 30; // 3600 * 24 = 1 day const DEFAULT_TTL = 3600; // short TTL for testing -export const set = async (key: string, data: any, ttl?: number) => { +export const set = async (key: string, data: unknown, ttl?: number) => { if (!redis) { return; } diff --git a/supabase/supabase-e2e.toml b/supabase/supabase-e2e.toml index d6ac39534..fd31972b1 100644 --- a/supabase/supabase-e2e.toml +++ b/supabase/supabase-e2e.toml @@ -70,7 +70,7 @@ objects_path = "./storage/avatars" [auth] enabled = true -site_url = "http://localhost:3100/" +site_url = "http://localhost:4100/" additional_redirect_urls = [ "https://127.0.0.1:3000" ] jwt_expiry = 3600 enable_refresh_token_rotation = true diff --git a/tests/e2e/README.md b/tests/e2e/README.md index f04de1f45..229371d10 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -36,14 +36,14 @@ pnpm e2e # headless pnpm e2e:ui # Playwright UI mode ``` -Playwright will automatically start `pnpm dev:e2e` (dev server on port 3100 with e2e env vars) and wait for it. +Playwright will automatically start `pnpm dev:e2e` (dev server on port 4100 with e2e env vars) and wait for it. ### Option 2: Manual dev server (for debugging) Terminal 1: ```bash -pnpm dev:e2e # Starts app at localhost:3100 with e2e Supabase +pnpm dev:e2e # Starts app at localhost:4100 with e2e Supabase ``` Terminal 2: @@ -92,7 +92,7 @@ test('authenticated test', async ({ authenticatedPage }) => { `playwright.config.ts` sets: -- `baseURL`: `http://localhost:3100` +- `baseURL`: `http://localhost:4100` - `webServer.command`: `pnpm dev:e2e` (auto-starts dev server) - `timeout`: 60s per test - `retries`: 2 in CI, 0 locally diff --git a/tests/e2e/fixtures/auth.ts b/tests/e2e/fixtures/auth.ts index c4630f165..a53e10324 100644 --- a/tests/e2e/fixtures/auth.ts +++ b/tests/e2e/fixtures/auth.ts @@ -204,7 +204,7 @@ export const test = base.extend({ cookies, origins: [ { - origin: 'http://localhost:3100', + origin: 'http://localhost:4100', localStorage: [ { name: storageKey, diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 416b2fe11..e2157e159 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -29,7 +29,7 @@ export default defineConfig({ timeout: 60_000, // 60 seconds per test use: { - baseURL: 'http://localhost:3100', + baseURL: 'http://localhost:4100', trace: 'on-first-retry', screenshot: 'only-on-failure', }, @@ -44,7 +44,7 @@ export default defineConfig({ /* Run dev servers with e2e environment before starting the tests */ webServer: { command: 'pnpm dev:e2e', - url: 'http://localhost:3100', + url: 'http://localhost:4100', reuseExistingServer: !process.env.CI, cwd: path.resolve(__dirname, '../..'), timeout: 120 * 1000, diff --git a/turbo.json b/turbo.json index ea0efc305..2704d6d9b 100644 --- a/turbo.json +++ b/turbo.json @@ -23,6 +23,10 @@ "dev": { "cache": false, "persistent": true + }, + "dev:e2e": { + "cache": false, + "persistent": true } }, "globalEnv": [