diff --git a/.env.local.example b/.env.local.example index 8bff4ed..369b3ae 100644 --- a/.env.local.example +++ b/.env.local.example @@ -31,3 +31,6 @@ JWT_SECRET=change-me-local-dev-only RENDER_URL=http://localhost:5173 # VITE_API_URL= # leave unset; Vite proxies to local API +# +# Frontend (put in frontend/.env.local — Vite reads that folder when you run `pnpm dev:frontend`): +# VITE_GOOGLE_CLIENT_ID=your-web-client-id.apps.googleusercontent.com diff --git a/README.md b/README.md index 14c5269..6a2cb8d 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,11 @@ Designing board games is hard. It requires **rapid iteration** and the ability t Develop against **Docker Postgres** and **LocalStack** (S3/SQS). No real AWS calls. When you are ready, **push to `main`** and [GitHub Actions](.github/workflows/ci.yml) builds and deploys to ECS. -| Piece | Where it runs locally | -| ----- | --------------------- | -| Frontend + API + worker | Your machine | -| Database | Docker Postgres (`localhost:5433`) | -| S3 / SQS | LocalStack (`localhost:4566`) | +| Piece | Where it runs locally | +| ----------------------- | ---------------------------------- | +| Frontend + API + worker | Your machine | +| Database | Docker Postgres (`localhost:5433`) | +| S3 / SQS | LocalStack (`localhost:4566`) | The API container can serve the built SPA in production (`NODE_ENV=production`). Until you add a stable URL (ALB, CloudFront, etc.), you may use the task public IP for smoke tests. @@ -115,10 +115,10 @@ ECS tasks do **not** read `.env.production`; they get env from Terraform. ## Environment files -| File | Purpose | -| ---------------------------------------------------- | ------- | +| File | Purpose | +| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | [`.env.local.example`](.env.local.example) | Template → **`.env.local`** — local dev (Docker Postgres + LocalStack). Used by `pnpm dev:local`, `pnpm dev:api`, `pnpm migrate:deploy`. | -| [`.env.production.example`](.env.production.example) | Template → **`.env.production`** — **only** for `pnpm migrate:deploy:prod` against RDS. | +| [`.env.production.example`](.env.production.example) | Template → **`.env.production`** — **only** for `pnpm migrate:deploy:prod` against RDS. | The API loads **`.env.local`** via `dotenv-cli` on `pnpm dev` scripts. The worker loads **`.env.local`** when run locally ([`worker/README.md`](worker/README.md)). diff --git a/api/package.json b/api/package.json index 3534432..8065b1f 100644 --- a/api/package.json +++ b/api/package.json @@ -9,6 +9,7 @@ "start": "node dist/index.js", "lint": "eslint src/", "test": "vitest run", + "test:coverage": "vitest run --coverage", "test:watch": "vitest", "prisma:generate": "dotenv -e ../.env.local -- prisma generate", "prisma:migrate": "dotenv -e ../.env.local -- prisma migrate dev", @@ -36,10 +37,13 @@ "@types/jsonwebtoken": "^9.0.7", "@types/multer": "^1.4.12", "@types/node": "^22.10.5", + "@types/supertest": "^7.2.0", + "@vitest/coverage-v8": "^3.2.4", "dotenv": "^16.4.7", "dotenv-cli": "^8.0.0", "eslint": "^9.17.0", "prisma": "^6.2.0", + "supertest": "^7.2.2", "tsx": "^4.19.2", "typescript": "^5.7.2", "typescript-eslint": "^8.57.0", diff --git a/api/prisma/migrations/20260409120000_user_nullable_password_hash/migration.sql b/api/prisma/migrations/20260409120000_user_nullable_password_hash/migration.sql new file mode 100644 index 0000000..5c92140 --- /dev/null +++ b/api/prisma/migrations/20260409120000_user_nullable_password_hash/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable: allow OAuth users without a password +ALTER TABLE "User" ALTER COLUMN "password_hash" DROP NOT NULL; diff --git a/api/prisma/migrations/20260410120000_remove_apple_sub/migration.sql b/api/prisma/migrations/20260410120000_remove_apple_sub/migration.sql new file mode 100644 index 0000000..4f86f1b --- /dev/null +++ b/api/prisma/migrations/20260410120000_remove_apple_sub/migration.sql @@ -0,0 +1,3 @@ +-- Drop legacy apple_sub column if present (feature removed) +DROP INDEX IF EXISTS "User_apple_sub_key"; +ALTER TABLE "User" DROP COLUMN IF EXISTS "apple_sub"; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 183192f..a149230 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -7,11 +7,11 @@ datasource db { url = env("DATABASE_URL") } -/// Auth: username + password (hashed); JWT issued separately. +/// Auth: unique `username` stores normalized email; password optional for Google-only accounts. model User { id String @id @default(uuid()) @db.Uuid username String @unique - passwordHash String @map("password_hash") + passwordHash String? @map("password_hash") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/api/src/app.test.ts b/api/src/app.test.ts new file mode 100644 index 0000000..df612c9 --- /dev/null +++ b/api/src/app.test.ts @@ -0,0 +1,19 @@ +import request from 'supertest'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('./lib/prisma.js', async () => { + const { prisma } = await import('./test/prisma-mock.js'); + return { prisma }; +}); + +import { createApp } from './app.js'; + +describe('createApp', () => { + const app = createApp(); + + it('GET /health', async () => { + const res = await request(app).get('/health'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: 'ok', service: 'cardgoose-api' }); + }); +}); diff --git a/api/src/app.ts b/api/src/app.ts new file mode 100644 index 0000000..6290d5f --- /dev/null +++ b/api/src/app.ts @@ -0,0 +1,62 @@ +import { randomUUID } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import cors from 'cors'; +import express, { type Express } from 'express'; +import { pinoHttp } from 'pino-http'; +import { authRouter } from './routes/auth.js'; +import { projectsRouter } from './routes/projects.js'; +import { assetsRouter } from './routes/assets.js'; +import { exportsRouter } from './routes/exports.js'; +import { rootLogger } from './lib/logger.js'; + +/** Express app with all routes and middleware (does not listen). */ +export function createApp(): Express { + const app = express(); + + app.use( + pinoHttp({ + logger: rootLogger, + genReqId: () => randomUUID(), + customSuccessMessage: (req, res) => `${req.method} ${req.url} ${res.statusCode}`, + customErrorMessage: (req, res, err) => + `${req.method} ${req.url} ${res.statusCode} — ${err instanceof Error ? err.message : 'error'}`, + }) + ); + + const corsOrigin = process.env.CORS_ORIGIN; + app.use( + cors({ + origin: corsOrigin === '*' || !corsOrigin ? true : corsOrigin.split(',').map((s) => s.trim()), + credentials: true, + }) + ); + app.use(express.json()); + + app.get('/health', (_req, res) => { + res.json({ status: 'ok', service: 'cardgoose-api' }); + }); + + app.use('/api/auth', authRouter); + app.use('/api/projects', projectsRouter); + app.use('/api', assetsRouter); + app.use('/api', exportsRouter); + + const appDir = dirname(fileURLToPath(import.meta.url)); + const publicDir = join(appDir, 'public'); + if (existsSync(publicDir) && process.env.NODE_ENV === 'production') { + rootLogger.info({ publicDir }, 'Serving SPA from API container'); + app.use(express.static(publicDir)); + app.get('*', (req, res, next) => { + if (req.method !== 'GET' && req.method !== 'HEAD') return next(); + if (req.path.startsWith('/api')) return next(); + if (req.path === '/health') return next(); + res.sendFile(join(publicDir, 'index.html'), (err) => { + if (err) next(err); + }); + }); + } + + return app; +} diff --git a/api/src/health.test.ts b/api/src/health.test.ts deleted file mode 100644 index b78e866..0000000 --- a/api/src/health.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -describe('health placeholder', () => { - it('passes', () => { - expect(true).toBe(true); - }); -}); diff --git a/api/src/index.ts b/api/src/index.ts index bc7a0b7..7fb4802 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,70 +1,15 @@ -import { randomUUID } from 'node:crypto'; -import { existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import cors from 'cors'; -import express from 'express'; -import { pinoHttp } from 'pino-http'; -import { authRouter } from './routes/auth.js'; -import { projectsRouter } from './routes/projects.js'; -import { assetsRouter } from './routes/assets.js'; -import { exportsRouter } from './routes/exports.js'; -import { rootLogger } from './lib/logger.js'; +import { createApp } from './app.js'; import { assertDevProfileIfSet } from './lib/devProfile.js'; import { ensureDevLocalStackBuckets } from './lib/s3.js'; +import { rootLogger } from './lib/logger.js'; -// Local env: `pnpm dev:api` / `pnpm dev:local` use dotenv-cli to load ../.env.local before imports. - -const app = express(); const port = Number(process.env.PORT) || 3001; -app.use( - pinoHttp({ - logger: rootLogger, - genReqId: () => randomUUID(), - customSuccessMessage: (req, res) => `${req.method} ${req.url} ${res.statusCode}`, - customErrorMessage: (req, res, err) => - `${req.method} ${req.url} ${res.statusCode} — ${err instanceof Error ? err.message : 'error'}`, - }) -); - -const corsOrigin = process.env.CORS_ORIGIN; -app.use( - cors({ - origin: corsOrigin === '*' || !corsOrigin ? true : corsOrigin.split(',').map((s) => s.trim()), - credentials: true, - }) -); -app.use(express.json()); - -app.get('/health', (_req, res) => { - res.json({ status: 'ok', service: 'cardgoose-api' }); -}); - -app.use('/api/auth', authRouter); -app.use('/api/projects', projectsRouter); -app.use('/api', assetsRouter); -app.use('/api', exportsRouter); - -const appDir = dirname(fileURLToPath(import.meta.url)); -const publicDir = join(appDir, 'public'); -if (existsSync(publicDir) && process.env.NODE_ENV === 'production') { - rootLogger.info({ publicDir }, 'Serving SPA from API container'); - app.use(express.static(publicDir)); - app.get('*', (req, res, next) => { - if (req.method !== 'GET' && req.method !== 'HEAD') return next(); - if (req.path.startsWith('/api')) return next(); - if (req.path === '/health') return next(); - res.sendFile(join(publicDir, 'index.html'), (err) => { - if (err) next(err); - }); - }); -} - assertDevProfileIfSet(); void (async () => { await ensureDevLocalStackBuckets(); + const app = createApp(); app.listen(port, '0.0.0.0', () => { rootLogger.info( { diff --git a/api/src/lib/csv.test.ts b/api/src/lib/csv.test.ts new file mode 100644 index 0000000..b997dfb --- /dev/null +++ b/api/src/lib/csv.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { assertHttpsCsvUrl, parseCsvLine, parseCsvText } from './csv.js'; + +describe('parseCsvLine', () => { + it('splits simple commas', () => { + expect(parseCsvLine('a,b,c')).toEqual(['a', 'b', 'c']); + }); + + it('handles quoted fields with commas', () => { + expect(parseCsvLine('"a,b",c')).toEqual(['a,b', 'c']); + }); + + it('escapes doubled quotes', () => { + expect(parseCsvLine('"a""b",x')).toEqual(['a"b', 'x']); + }); + + it('toggles quote state', () => { + expect(parseCsvLine('"hello",world')).toEqual(['hello', 'world']); + }); +}); + +describe('parseCsvText', () => { + it('returns empty for empty input', () => { + expect(parseCsvText('')).toEqual({ headers: [], rows: [] }); + }); + + it('parses header and rows', () => { + const r = parseCsvText('Name,Score\nAlice,10\nBob,20'); + expect(r.headers).toEqual(['Name', 'Score']); + expect(r.rows).toEqual([ + { Name: 'Alice', Score: '10' }, + { Name: 'Bob', Score: '20' }, + ]); + }); + + it('handles CRLF', () => { + const r = parseCsvText('A\r\n1'); + expect(r.headers).toEqual(['A']); + expect(r.rows).toEqual([{ A: '1' }]); + }); + + it('skips empty header cells when building headers array', () => { + const r = parseCsvText('A,,B\n1,2,3'); + expect(r.headers).toEqual(['A', 'B']); + }); +}); + +describe('assertHttpsCsvUrl', () => { + it('accepts valid https URL', () => { + expect(assertHttpsCsvUrl('https://example.com/x.csv')).toBe('https://example.com/x.csv'); + }); + + it('trims whitespace', () => { + expect(assertHttpsCsvUrl(' https://example.com/x ')).toBe('https://example.com/x'); + }); + + it('rejects http', () => { + expect(() => assertHttpsCsvUrl('http://example.com')).toThrow('Only https://'); + }); + + it('rejects invalid URL', () => { + expect(() => assertHttpsCsvUrl('not a url')).toThrow('Invalid URL'); + }); + + it('rejects long URL', () => { + const long = `https://example.com/${'x'.repeat(2100)}`; + expect(() => assertHttpsCsvUrl(long)).toThrow('URL too long'); + }); +}); diff --git a/api/src/lib/devProfile.test.ts b/api/src/lib/devProfile.test.ts new file mode 100644 index 0000000..0d123b5 --- /dev/null +++ b/api/src/lib/devProfile.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { assertDevProfileIfSet } from './devProfile.js'; + +describe('assertDevProfileIfSet', () => { + const env = { ...process.env }; + + afterEach(() => { + process.env = { ...env }; + vi.unstubAllGlobals(); + }); + + it('no-ops in production', () => { + process.env.NODE_ENV = 'production'; + process.env.CARDGOOSE_DEV_PROFILE = 'fully-local'; + expect(() => assertDevProfileIfSet()).not.toThrow(); + }); + + it('no-ops when profile not fully-local', () => { + process.env.NODE_ENV = 'development'; + process.env.CARDGOOSE_DEV_PROFILE = ''; + expect(() => assertDevProfileIfSet()).not.toThrow(); + }); + + it('throws when DB not local:5433', () => { + process.env.NODE_ENV = 'development'; + process.env.CARDGOOSE_DEV_PROFILE = 'fully-local'; + process.env.DATABASE_URL = 'postgresql://x@remote:5432/db'; + process.env.AWS_ENDPOINT_URL = 'http://127.0.0.1:4566'; + process.env.SQS_QUEUE_URL = 'http://localhost:4566/queue'; + expect(() => assertDevProfileIfSet()).toThrow(/5433/); + }); + + it('throws when AWS endpoint not LocalStack', () => { + process.env.NODE_ENV = 'development'; + process.env.CARDGOOSE_DEV_PROFILE = 'fully-local'; + process.env.DATABASE_URL = 'postgresql://u@127.0.0.1:5433/db'; + process.env.AWS_ENDPOINT_URL = 'https://s3.amazonaws.com'; + process.env.SQS_QUEUE_URL = 'http://localhost:4566/q'; + expect(() => assertDevProfileIfSet()).toThrow(/LocalStack/); + }); + + it('throws when SQS points at real AWS', () => { + process.env.NODE_ENV = 'development'; + process.env.CARDGOOSE_DEV_PROFILE = 'fully-local'; + process.env.DATABASE_URL = 'postgresql://u@127.0.0.1:5433/db'; + process.env.AWS_ENDPOINT_URL = 'http://127.0.0.1:4566'; + process.env.SQS_QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123/x'; + expect(() => assertDevProfileIfSet()).toThrow(/amazonaws/); + }); + + it('passes with valid fully-local env', () => { + process.env.NODE_ENV = 'development'; + process.env.CARDGOOSE_DEV_PROFILE = 'fully-local'; + process.env.DATABASE_URL = 'postgresql://u@localhost:5433/db'; + process.env.AWS_ENDPOINT_URL = 'http://127.0.0.1:4566'; + process.env.SQS_QUEUE_URL = 'http://localhost:4566/q'; + expect(() => assertDevProfileIfSet()).not.toThrow(); + }); +}); diff --git a/api/src/lib/jwt.test.ts b/api/src/lib/jwt.test.ts new file mode 100644 index 0000000..df8ca73 --- /dev/null +++ b/api/src/lib/jwt.test.ts @@ -0,0 +1,21 @@ +import jwt from 'jsonwebtoken'; +import { describe, expect, it } from 'vitest'; +import { signToken, verifyToken } from './jwt.js'; + +describe('jwt', () => { + it('round-trips payload', () => { + const t = signToken({ sub: 'id1', username: 'u@x.com' }); + expect(verifyToken(t)).toEqual({ sub: 'id1', username: 'u@x.com' }); + }); + + it('rejects tampered token', () => { + const t = signToken({ sub: 'id1', username: 'u@x.com' }); + const bad = `${t.slice(0, -4)}xxxx`; + expect(() => verifyToken(bad)).toThrow(); + }); + + it('rejects token with wrong shape', () => { + const t = jwt.sign({ sub: 'only' }, process.env.JWT_SECRET!); + expect(() => verifyToken(t)).toThrow('Invalid token payload'); + }); +}); diff --git a/api/src/lib/layoutArtKeys.test.ts b/api/src/lib/layoutArtKeys.test.ts index 0c5fef9..d7a7218 100644 --- a/api/src/lib/layoutArtKeys.test.ts +++ b/api/src/lib/layoutArtKeys.test.ts @@ -33,4 +33,30 @@ describe('collectArtKeysFromLayoutState', () => { }); expect(keys).toEqual(['bg']); }); + + it('reads v1 elements array', () => { + const keys = collectArtKeysFromLayoutState({ + elements: [{ type: 'image', id: 'i', x: 0, y: 0, width: 1, height: 1, artKey: 'e1' }], + }); + expect(keys).toEqual(['e1']); + }); + + it('returns empty for non-object state', () => { + expect(collectArtKeysFromLayoutState(null)).toEqual([]); + expect(collectArtKeysFromLayoutState('x')).toEqual([]); + }); + + it('ignores empty artKey', () => { + const keys = collectArtKeysFromLayoutState({ + root: [{ type: 'image', id: 'i', x: 0, y: 0, width: 1, height: 1, artKey: ' ' }], + }); + expect(keys).toEqual([]); + }); + + it('trims art key', () => { + const keys = collectArtKeysFromLayoutState({ + root: [{ type: 'image', id: 'i', x: 0, y: 0, width: 1, height: 1, artKey: ' art ' }], + }); + expect(keys).toEqual(['art']); + }); }); diff --git a/api/src/lib/logger.test.ts b/api/src/lib/logger.test.ts new file mode 100644 index 0000000..89a8346 --- /dev/null +++ b/api/src/lib/logger.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; +import { rootLogger } from './logger.js'; + +describe('rootLogger', () => { + it('is defined with info', () => { + expect(rootLogger.level).toBeTruthy(); + }); +}); diff --git a/api/src/lib/pdfExportPayload.test.ts b/api/src/lib/pdfExportPayload.test.ts new file mode 100644 index 0000000..68e1fb2 --- /dev/null +++ b/api/src/lib/pdfExportPayload.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../lib/prisma.js', async () => { + const { prisma } = await import('../test/prisma-mock.js'); + return { prisma }; +}); + +vi.mock('../lib/s3.js', () => ({ + getAssetsBucket: () => 'assets-bucket', + getSignedGetUrl: vi.fn(async () => 'https://signed.example/asset'), +})); + +import { prisma } from '../test/prisma-mock.js'; +import { + buildPdfExportPayload, + EXPORT_PDF_DPI_MAX, + EXPORT_PDF_DPI_MIN, + resolveExportPdfDpi, +} from './pdfExportPayload.js'; + +describe('resolveExportPdfDpi', () => { + const env = process.env.EXPORT_PDF_DPI; + + afterEach(() => { + if (env === undefined) delete process.env.EXPORT_PDF_DPI; + else process.env.EXPORT_PDF_DPI = env; + }); + + it('clamps client number', () => { + expect(resolveExportPdfDpi(50)).toBe(EXPORT_PDF_DPI_MIN); + expect(resolveExportPdfDpi(400)).toBe(EXPORT_PDF_DPI_MAX); + expect(resolveExportPdfDpi(200)).toBe(200); + }); + + it('uses env when client missing', () => { + process.env.EXPORT_PDF_DPI = '175'; + expect(resolveExportPdfDpi(undefined)).toBe(175); + }); + + it('uses default when env invalid', () => { + process.env.EXPORT_PDF_DPI = 'nope'; + expect(resolveExportPdfDpi(undefined)).toBe(150); + }); +}); + +describe('buildPdfExportPayload', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns project not found', async () => { + prisma.project.findFirst.mockResolvedValueOnce(null); + const r = await buildPdfExportPayload('p', 'u'); + expect(r).toEqual({ error: 'Project not found' }); + }); + + it('returns error when no exportable groups', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p' }); + prisma.cardGroup.findMany.mockResolvedValueOnce([ + { layoutId: null, layout: null, csvData: null, name: 'g' }, + ]); + const r = await buildPdfExportPayload('p', 'u'); + expect(r).toMatchObject({ error: expect.stringContaining('No card groups') }); + }); + + it('builds payload with groups and assets', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p' }); + prisma.cardGroup.findMany.mockResolvedValueOnce([ + { + layoutId: 'L1', + layout: { + state: { + version: 2, + width: 100, + height: 100, + root: [ + { + type: 'image', + id: 'i', + x: 0, + y: 0, + width: 10, + height: 10, + artKey: 'art1', + }, + ], + }, + }, + csvData: { headers: ['A'], rows: [{ A: '1' }] }, + name: 'Deck', + }, + ]); + prisma.asset.findMany.mockResolvedValueOnce([{ artKey: 'art1', s3Key: 'k1' }]); + + const r = await buildPdfExportPayload('p', 'u', { dpi: 200 }); + if ('error' in r) throw new Error(String(r.error)); + expect(r.payload.type).toBe('export-pdf'); + expect(r.payload.dpi).toBe(200); + expect(r.payload.assetUrls).toEqual({ art1: 'https://signed.example/asset' }); + }); +}); diff --git a/api/src/lib/s3.test.ts b/api/src/lib/s3.test.ts new file mode 100644 index 0000000..fe1e352 --- /dev/null +++ b/api/src/lib/s3.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const send = vi.fn(); + +vi.mock('@aws-sdk/client-s3', () => ({ + S3Client: class { + send = send; + }, + CreateBucketCommand: class { + constructor(public input: unknown) {} + }, + HeadBucketCommand: class { + constructor(public input: unknown) {} + }, + ListObjectsV2Command: class { + constructor(public input: unknown) {} + }, + PutObjectCommand: class { + constructor(public input: unknown) {} + }, + GetObjectCommand: class { + constructor(public input: unknown) {} + }, +})); + +vi.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: vi.fn(async () => 'https://signed.example/presigned'), +})); + +describe('s3 helpers', () => { + beforeEach(() => { + send.mockReset(); + }); + + it('getAssetsBucket throws when unset', async () => { + const prev = process.env.S3_BUCKET_ASSETS; + delete process.env.S3_BUCKET_ASSETS; + const { getAssetsBucket } = await import('./s3.js'); + expect(() => getAssetsBucket()).toThrow('S3_BUCKET_ASSETS'); + process.env.S3_BUCKET_ASSETS = prev; + }); + + it('getExportsBucket throws when unset', async () => { + const prev = process.env.S3_BUCKET_EXPORTS; + delete process.env.S3_BUCKET_EXPORTS; + const { getExportsBucket } = await import('./s3.js'); + expect(() => getExportsBucket()).toThrow('S3_BUCKET_EXPORTS'); + process.env.S3_BUCKET_EXPORTS = prev; + }); + + it('putObject sends PutObjectCommand', async () => { + send.mockResolvedValueOnce({}); + const { putObject, getAssetsBucket } = await import('./s3.js'); + await putObject(getAssetsBucket(), 'k', Buffer.from('x'), 'text/plain'); + expect(send).toHaveBeenCalled(); + }); + + it('listObjectKeys paginates', async () => { + send + .mockResolvedValueOnce({ + Contents: [{ Key: 'a' }], + IsTruncated: true, + NextContinuationToken: 't1', + }) + .mockResolvedValueOnce({ + Contents: [{ Key: 'b' }], + IsTruncated: false, + }); + const { listObjectKeys, getAssetsBucket } = await import('./s3.js'); + const keys = await listObjectKeys(getAssetsBucket(), 'pre/'); + expect(keys).toEqual(['a', 'b']); + }); + + it('getSignedGetUrl returns presigned string', async () => { + send.mockResolvedValueOnce({}); + const { getSignedGetUrl, getAssetsBucket } = await import('./s3.js'); + const u = await getSignedGetUrl(getAssetsBucket(), 'key', 60); + expect(u).toBe('https://signed.example/presigned'); + }); + + it('ensureDevLocalStackBuckets no-ops in production', async () => { + const prev = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + const { ensureDevLocalStackBuckets } = await import('./s3.js'); + await ensureDevLocalStackBuckets(); + expect(send).not.toHaveBeenCalled(); + process.env.NODE_ENV = prev; + }); + + it('ensureDevLocalStackBuckets creates bucket when head fails', async () => { + const prevNode = process.env.NODE_ENV; + const prevAws = process.env.AWS_ENDPOINT_URL; + process.env.NODE_ENV = 'development'; + process.env.AWS_ENDPOINT_URL = 'http://localhost:4566'; + send.mockRejectedValueOnce(new Error('no bucket')).mockResolvedValueOnce({}); + const { ensureDevLocalStackBuckets } = await import('./s3.js'); + await ensureDevLocalStackBuckets(); + expect(send.mock.calls.length).toBeGreaterThan(0); + process.env.NODE_ENV = prevNode; + process.env.AWS_ENDPOINT_URL = prevAws; + }); + + it('ensureDevLocalStackBuckets ignores bucket already exists', async () => { + const prevNode = process.env.NODE_ENV; + const prevAws = process.env.AWS_ENDPOINT_URL; + process.env.NODE_ENV = 'development'; + process.env.AWS_ENDPOINT_URL = 'http://127.0.0.1:4566'; + const err = new Error('exists'); + (err as Error & { name: string }).name = 'BucketAlreadyOwnedByYou'; + send.mockRejectedValueOnce(new Error('no head')).mockRejectedValueOnce(err); + const { ensureDevLocalStackBuckets } = await import('./s3.js'); + await ensureDevLocalStackBuckets(); + process.env.NODE_ENV = prevNode; + process.env.AWS_ENDPOINT_URL = prevAws; + }); +}); diff --git a/api/src/lib/sqs.test.ts b/api/src/lib/sqs.test.ts new file mode 100644 index 0000000..e8f859c --- /dev/null +++ b/api/src/lib/sqs.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const send = vi.fn(); + +vi.mock('@aws-sdk/client-sqs', () => ({ + SQSClient: class { + send = send; + }, + SendMessageCommand: class { + constructor(public input: unknown) {} + }, +})); + +describe('sqs', () => { + beforeEach(() => { + send.mockReset(); + send.mockResolvedValue({}); + }); + + it('getQueueUrl throws when unset', async () => { + const prev = process.env.SQS_QUEUE_URL; + delete process.env.SQS_QUEUE_URL; + const { getQueueUrl } = await import('./sqs.js'); + expect(() => getQueueUrl()).toThrow('SQS_QUEUE_URL'); + process.env.SQS_QUEUE_URL = prev; + }); + + it('sendJsonMessage sends to queue', async () => { + const { sendJsonMessage } = await import('./sqs.js'); + await sendJsonMessage({ hello: 'world' }); + expect(send).toHaveBeenCalled(); + }); +}); diff --git a/api/src/middleware/auth.test.ts b/api/src/middleware/auth.test.ts new file mode 100644 index 0000000..deee02d --- /dev/null +++ b/api/src/middleware/auth.test.ts @@ -0,0 +1,38 @@ +import express from 'express'; +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; +import { signToken } from '../lib/jwt.js'; +import { requireAuth } from './auth.js'; + +describe('requireAuth', () => { + const app = express(); + app.use(requireAuth); + app.get('/x', (_req, res) => res.json({ ok: true })); + + it('returns 401 without header', async () => { + const res = await request(app).get('/x'); + expect(res.status).toBe(401); + }); + + it('returns 401 for bad bearer', async () => { + const res = await request(app).get('/x').set('Authorization', 'Bearer '); + expect(res.status).toBe(401); + }); + + it('returns 401 when bearer token is only whitespace', async () => { + const res = await request(app).get('/x').set('Authorization', 'Bearer '); + expect(res.status).toBe(401); + }); + + it('returns 401 for invalid token', async () => { + const res = await request(app).get('/x').set('Authorization', 'Bearer bad.token'); + expect(res.status).toBe(401); + }); + + it('allows valid token', async () => { + const token = signToken({ sub: 'u1', username: 'a@b.com' }); + const res = await request(app).get('/x').set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + }); +}); diff --git a/api/src/routes/assets.test.ts b/api/src/routes/assets.test.ts new file mode 100644 index 0000000..027f368 --- /dev/null +++ b/api/src/routes/assets.test.ts @@ -0,0 +1,130 @@ +import request from 'supertest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { signToken } from '../lib/jwt.js'; + +const s3Mocks = vi.hoisted(() => ({ + putObject: vi.fn(async () => {}), + getSignedGetUrl: vi.fn(async () => 'https://signed.example/o'), +})); + +vi.mock('../lib/prisma.js', async () => { + const { prisma } = await import('../test/prisma-mock.js'); + return { prisma }; +}); + +vi.mock('../lib/s3.js', () => ({ + getAssetsBucket: () => 'assets-bucket', + putObject: s3Mocks.putObject, + getSignedGetUrl: s3Mocks.getSignedGetUrl, +})); + +import { prisma } from '../test/prisma-mock.js'; + +import { createApp } from '../app.js'; + +const app = createApp(); + +function authed() { + const token = signToken({ sub: 'u1', username: 'a@b.com' }); + return { Authorization: `Bearer ${token}` }; +} + +describe('assets routes', () => { + beforeEach(() => { + s3Mocks.putObject.mockClear(); + s3Mocks.putObject.mockResolvedValue(undefined); + }); + + it('404 when project missing', async () => { + prisma.project.findFirst.mockResolvedValueOnce(null); + const res = await request(app).get('/api/projects/p1/assets').set(authed()); + expect(res.status).toBe(404); + }); + + it('lists assets without URLs', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' } as never); + prisma.asset.findMany.mockResolvedValueOnce([ + { id: '1', artKey: 'a', s3Key: 'k', createdAt: new Date(), updatedAt: new Date() }, + ]); + const res = await request(app).get('/api/projects/p1/assets').set(authed()); + expect(res.status).toBe(200); + expect(res.body.assets[0].url).toBeUndefined(); + }); + + it('includes signed URLs when requested', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' } as never); + prisma.asset.findMany.mockResolvedValueOnce([ + { id: '1', artKey: 'a', s3Key: 'k', createdAt: new Date(), updatedAt: new Date() }, + ]); + const res = await request(app).get('/api/projects/p1/assets?includeUrls=1').set(authed()); + expect(res.status).toBe(200); + expect(res.body.assets[0].url).toBe('https://signed.example/o'); + }); + + it('400 upload without file', async () => { + const res = await request(app) + .post('/api/projects/p1/assets') + .set(authed()) + .field('artKey', 'hero'); + expect(res.status).toBe(400); + }); + + it('404 upload when project missing', async () => { + prisma.project.findFirst.mockResolvedValueOnce(null); + const res = await request(app) + .post('/api/projects/p1/assets') + .set(authed()) + .attach('file', Buffer.from('x'), 'x.png'); + expect(res.status).toBe(404); + }); + + it('uses sanitized filename when artKey omitted', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' } as never); + prisma.asset.upsert.mockResolvedValueOnce({ + id: 'a1', + artKey: 'x', + s3Key: 'p1/x', + createdAt: new Date(), + updatedAt: new Date(), + }); + const res = await request(app) + .post('/api/projects/p1/assets') + .set(authed()) + .attach('file', Buffer.from('x'), 'weird!!!name?.png'); + expect(res.status).toBe(201); + }); + + it('uses explicit artKey from body', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' } as never); + prisma.asset.upsert.mockResolvedValueOnce({ + id: 'a1', + artKey: 'hero', + s3Key: 'p1/hero', + createdAt: new Date(), + updatedAt: new Date(), + }); + const res = await request(app) + .post('/api/projects/p1/assets') + .set(authed()) + .field('artKey', 'hero') + .attach('file', Buffer.from('d'), 'ignored.png'); + expect(res.status).toBe(201); + }); + + it('201 upload file', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' } as never); + prisma.asset.upsert.mockResolvedValueOnce({ + id: 'a1', + artKey: 'pic', + s3Key: 'p1/pic', + createdAt: new Date(), + updatedAt: new Date(), + }); + const res = await request(app) + .post('/api/projects/p1/assets') + .set(authed()) + .attach('file', Buffer.from('png-bytes'), 'card.png'); + expect(res.status).toBe(201); + expect(s3Mocks.putObject).toHaveBeenCalled(); + }); +}); diff --git a/api/src/routes/auth.test.ts b/api/src/routes/auth.test.ts new file mode 100644 index 0000000..e834fac --- /dev/null +++ b/api/src/routes/auth.test.ts @@ -0,0 +1,204 @@ +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../lib/prisma.js', async () => { + const { prisma } = await import('../test/prisma-mock.js'); + return { prisma }; +}); + +import { prisma } from '../test/prisma-mock.js'; + +import { createApp } from '../app.js'; + +const app = createApp(); + +describe('auth routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('POST /api/auth/register', () => { + it('400 when missing fields', async () => { + const res = await request(app).post('/api/auth/register').send({}); + expect(res.status).toBe(400); + }); + + it('400 invalid email', async () => { + const res = await request(app) + .post('/api/auth/register') + .send({ email: 'not-an-email', password: 'secret12' }); + expect(res.status).toBe(400); + }); + + it('400 short password', async () => { + const res = await request(app) + .post('/api/auth/register') + .send({ email: 'a@b.com', password: '12345' }); + expect(res.status).toBe(400); + }); + + it('409 when google-only account exists', async () => { + prisma.user.findUnique.mockResolvedValueOnce({ + id: 'x', + username: 'a@b.com', + passwordHash: null, + } as never); + const res = await request(app) + .post('/api/auth/register') + .send({ email: 'a@b.com', password: 'secret12' }); + expect(res.status).toBe(409); + expect(res.body.error).toMatch(/Google/); + }); + + it('409 when password account exists', async () => { + prisma.user.findUnique.mockResolvedValueOnce({ + id: 'x', + username: 'a@b.com', + passwordHash: 'h', + } as never); + const res = await request(app) + .post('/api/auth/register') + .send({ email: 'a@b.com', password: 'secret12' }); + expect(res.status).toBe(409); + }); + + it('201 registers', async () => { + prisma.user.findUnique.mockResolvedValueOnce(null); + prisma.user.create.mockResolvedValueOnce({ id: 'u1', username: 'new@b.com' } as never); + const res = await request(app) + .post('/api/auth/register') + .send({ email: 'new@b.com', password: 'secret12' }); + expect(res.status).toBe(201); + expect(res.body.token).toBeTruthy(); + expect(res.body.user.username).toBe('new@b.com'); + }); + }); + + describe('POST /api/auth/login', () => { + it('400 missing', async () => { + const res = await request(app).post('/api/auth/login').send({}); + expect(res.status).toBe(400); + }); + + it('401 unknown user', async () => { + prisma.user.findUnique.mockResolvedValueOnce(null); + const res = await request(app) + .post('/api/auth/login') + .send({ email: 'a@b.com', password: 'secret12' }); + expect(res.status).toBe(401); + }); + + it('401 google-only', async () => { + prisma.user.findUnique.mockResolvedValueOnce({ + id: 'u', + username: 'a@b.com', + passwordHash: null, + } as never); + const res = await request(app) + .post('/api/auth/login') + .send({ email: 'a@b.com', password: 'secret12' }); + expect(res.status).toBe(401); + expect(res.body.error).toMatch(/Google/); + }); + + it('401 bad password', async () => { + prisma.user.findUnique.mockResolvedValueOnce({ + id: 'u', + username: 'a@b.com', + passwordHash: 'not-bcrypt', + } as never); + const res = await request(app) + .post('/api/auth/login') + .send({ email: 'a@b.com', password: 'wrongpass' }); + expect(res.status).toBe(401); + }); + + it('200 logs in', async () => { + const hash = '$2a$10$abcdefghijklmnopqrstuv/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; + prisma.user.findUnique.mockResolvedValueOnce({ + id: 'u1', + username: 'ok@b.com', + passwordHash: hash, + } as never); + const bcrypt = await import('bcryptjs'); + vi.spyOn(bcrypt.default, 'compare').mockResolvedValueOnce(true as never); + const res = await request(app) + .post('/api/auth/login') + .send({ email: 'ok@b.com', password: 'correcthorse' }); + expect(res.status).toBe(200); + expect(res.body.token).toBeTruthy(); + }); + }); + + describe('POST /api/auth/google', () => { + it('400 without token', async () => { + const res = await request(app).post('/api/auth/google').send({}); + expect(res.status).toBe(400); + }); + + it('401 when google rejects', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized' }) + ); + const res = await request(app).post('/api/auth/google').send({ accessToken: 'bad' }); + expect(res.status).toBe(401); + }); + + it('400 unverified email', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ email: 'a@b.com', email_verified: false }), + }) + ); + const res = await request(app).post('/api/auth/google').send({ accessToken: 't' }); + expect(res.status).toBe(400); + }); + + it('400 invalid email string from Google', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ email: 'not-an-email', email_verified: true, sub: 's' }), + }) + ); + const res = await request(app).post('/api/auth/google').send({ accessToken: 't' }); + expect(res.status).toBe(400); + }); + + it('200 creates user', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ email: 'g@b.com', email_verified: true, sub: 'gp' }), + }) + ); + prisma.user.findUnique.mockResolvedValueOnce(null); + prisma.user.create.mockResolvedValueOnce({ id: 'u2', username: 'g@b.com' } as never); + const res = await request(app).post('/api/auth/google').send({ accessToken: 't' }); + expect(res.status).toBe(200); + expect(res.body.user.username).toBe('g@b.com'); + }); + + it('200 existing user', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ email: 'ex@b.com', email_verified: true, sub: 'gp' }), + }) + ); + prisma.user.findUnique.mockResolvedValueOnce({ id: 'u3', username: 'ex@b.com' } as never); + const res = await request(app).post('/api/auth/google').send({ accessToken: 't' }); + expect(res.status).toBe(200); + }); + }); +}); diff --git a/api/src/routes/auth.ts b/api/src/routes/auth.ts index a545139..94ce04b 100644 --- a/api/src/routes/auth.ts +++ b/api/src/routes/auth.ts @@ -5,26 +5,50 @@ import { signToken } from '../lib/jwt.js'; export const authRouter: IRouter = Router(); +const GOOGLE_USERINFO = 'https://www.googleapis.com/oauth2/v3/userinfo'; + +function normalizeEmail(input: string): string { + return input.trim().toLowerCase(); +} + +function isValidEmail(s: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s); +} + authRouter.post('/register', async (req, res) => { - const { username, password } = req.body as { username?: string; password?: string }; - if (!username || !password || typeof username !== 'string' || typeof password !== 'string') { - res.status(400).json({ error: 'username and password are required' }); + const body = req.body as { email?: string; username?: string; password?: string }; + const raw = body.email ?? body.username; + const password = body.password; + if (!raw || !password || typeof raw !== 'string' || typeof password !== 'string') { + res.status(400).json({ error: 'email and password are required' }); return; } - if (username.length < 2 || password.length < 6) { - res.status(400).json({ error: 'username min 2 chars, password min 6 chars' }); + + const email = normalizeEmail(raw); + if (!isValidEmail(email)) { + res.status(400).json({ error: 'Enter a valid email address' }); + return; + } + if (password.length < 6) { + res.status(400).json({ error: 'Password must be at least 6 characters' }); return; } - const existing = await prisma.user.findUnique({ where: { username } }); + const existing = await prisma.user.findUnique({ where: { username: email } }); if (existing) { - res.status(409).json({ error: 'Username already taken' }); + if (!existing.passwordHash) { + res.status(409).json({ + error: 'An account with this email already exists. Sign in with Google.', + }); + return; + } + res.status(409).json({ error: 'An account with this email already exists' }); return; } const passwordHash = await bcrypt.hash(password, 10); const user = await prisma.user.create({ - data: { username, passwordHash }, + data: { username: email, passwordHash }, }); const token = signToken({ sub: user.id, username: user.username }); @@ -36,21 +60,29 @@ authRouter.post('/register', async (req, res) => { }); authRouter.post('/login', async (req, res) => { - const { username, password } = req.body as { username?: string; password?: string }; - if (!username || !password || typeof username !== 'string' || typeof password !== 'string') { - res.status(400).json({ error: 'username and password are required' }); + const body = req.body as { email?: string; username?: string; password?: string }; + const raw = body.email ?? body.username; + const password = body.password; + if (!raw || !password || typeof raw !== 'string' || typeof password !== 'string') { + res.status(400).json({ error: 'email and password are required' }); return; } - const user = await prisma.user.findUnique({ where: { username } }); + const email = normalizeEmail(raw); + const user = await prisma.user.findUnique({ where: { username: email } }); if (!user) { - res.status(401).json({ error: 'Invalid credentials' }); + res.status(401).json({ error: 'Invalid email or password' }); + return; + } + + if (!user.passwordHash) { + res.status(401).json({ error: 'This account uses Google sign-in' }); return; } const ok = await bcrypt.compare(password, user.passwordHash); if (!ok) { - res.status(401).json({ error: 'Invalid credentials' }); + res.status(401).json({ error: 'Invalid email or password' }); return; } @@ -61,3 +93,53 @@ authRouter.post('/login', async (req, res) => { user: { id: user.id, username: user.username }, }); }); + +authRouter.post('/google', async (req, res) => { + const { accessToken } = req.body as { accessToken?: string }; + if (!accessToken || typeof accessToken !== 'string') { + res.status(400).json({ error: 'accessToken is required' }); + return; + } + + const gr = await fetch(GOOGLE_USERINFO, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!gr.ok) { + req.log.warn({ status: gr.status }, 'auth.google userinfo failed'); + res.status(401).json({ error: 'Invalid or expired Google session' }); + return; + } + + const profile = (await gr.json()) as { + sub?: string; + email?: string; + email_verified?: boolean; + }; + + if (!profile.email || profile.email_verified !== true) { + res.status(400).json({ error: 'Google did not return a verified email' }); + return; + } + + const email = normalizeEmail(profile.email); + if (!isValidEmail(email)) { + res.status(400).json({ error: 'Invalid email from Google' }); + return; + } + + let user = await prisma.user.findUnique({ where: { username: email } }); + if (!user) { + user = await prisma.user.create({ + data: { username: email, passwordHash: null }, + }); + req.log.info({ userId: user.id, username: user.username }, 'auth.google register ok'); + } else { + req.log.info({ userId: user.id, username: user.username }, 'auth.google login ok'); + } + + const token = signToken({ sub: user.id, username: user.username }); + res.json({ + token, + user: { id: user.id, username: user.username }, + }); +}); diff --git a/api/src/routes/exports.test.ts b/api/src/routes/exports.test.ts new file mode 100644 index 0000000..7c4ec40 --- /dev/null +++ b/api/src/routes/exports.test.ts @@ -0,0 +1,370 @@ +import request from 'supertest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { signToken } from '../lib/jwt.js'; + +const sqsMocks = vi.hoisted(() => ({ + sendJsonMessage: vi.fn(async () => {}), +})); + +const s3ExportMocks = vi.hoisted(() => ({ + putObject: vi.fn(async () => {}), +})); + +vi.mock('../lib/prisma.js', async () => { + const { prisma } = await import('../test/prisma-mock.js'); + return { prisma }; +}); + +vi.mock('../lib/s3.js', () => ({ + getExportsBucket: () => 'exports-bucket', + getAssetsBucket: () => 'assets-bucket', + getSignedGetUrl: vi.fn(async () => 'https://signed'), + listObjectKeys: vi.fn(async () => ['p1/a.pdf', 'p1/b.pdf']), + putObject: s3ExportMocks.putObject, +})); + +vi.mock('../lib/sqs.js', () => ({ + sendJsonMessage: sqsMocks.sendJsonMessage, +})); + +vi.mock('node:child_process', () => ({ + spawnSync: vi.fn(() => ({ + status: 0, + stdout: JSON.stringify({ ok: true, s3Key: 'exports/out.pdf' }), + stderr: '', + error: undefined, + })), +})); + +import { spawnSync } from 'node:child_process'; +import { prisma } from '../test/prisma-mock.js'; +import { createApp } from '../app.js'; + +const app = createApp(); + +function authed() { + const token = signToken({ sub: 'u1', username: 'a@b.com' }); + return { Authorization: `Bearer ${token}` }; +} + +describe('exports routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('POST /export enqueues', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' } as never); + const res = await request(app).post('/api/projects/p1/export').set(authed()).send({}); + expect(res.status).toBe(200); + expect(res.body.queued).toBe(true); + expect(sqsMocks.sendJsonMessage).toHaveBeenCalled(); + }); + + it('POST /export 404', async () => { + prisma.project.findFirst.mockResolvedValueOnce(null); + const res = await request(app).post('/api/projects/p1/export').set(authed()).send({}); + expect(res.status).toBe(404); + }); + + it('POST /export-pdf 404', async () => { + prisma.project.findFirst.mockResolvedValueOnce(null); + const res = await request(app).post('/api/projects/p1/export-pdf').set(authed()).send({}); + expect(res.status).toBe(404); + }); + + it('POST /export-pdf 400 when no groups', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' } as never); + prisma.cardGroup.findMany.mockResolvedValueOnce([]); + const res = await request(app).post('/api/projects/p1/export-pdf').set(authed()).send({}); + expect(res.status).toBe(400); + }); + + it('POST /export-pdf queues inline', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' } as never); + prisma.cardGroup.findMany.mockResolvedValueOnce([ + { + layoutId: 'L1', + layout: { + state: { + version: 2, + width: 100, + height: 100, + root: [ + { + type: 'image', + id: 'i', + x: 0, + y: 0, + width: 10, + height: 10, + artKey: 'art1', + }, + ], + }, + }, + csvData: { headers: ['A'], rows: [{ A: '1' }] }, + name: 'G', + }, + ]); + prisma.asset.findMany.mockResolvedValueOnce([{ artKey: 'art1', s3Key: 'k' }]); + const res = await request(app) + .post('/api/projects/p1/export-pdf') + .set(authed()) + .send({ dpi: 200 }); + expect(res.status).toBe(200); + expect(sqsMocks.sendJsonMessage).toHaveBeenCalled(); + }); + + it('POST /export-pdf stores large payload in S3', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' } as never); + /** Few columns (≤64) but huge cell strings so JSON exceeds 200 KiB inline cap */ + const headers = Array.from({ length: 32 }, (_, i) => `c${i}`); + const bigRow = Object.fromEntries(headers.map((h) => [h, 'z'.repeat(8000)])); + prisma.cardGroup.findMany.mockResolvedValueOnce([ + { + layoutId: 'L1', + layout: { + state: { + version: 2, + width: 100, + height: 100, + root: Array.from({ length: 30 }, (_, j) => ({ + type: 'image' as const, + id: `i${j}`, + x: 0, + y: 0, + width: 10, + height: 10, + artKey: `art${j}`, + })), + }, + }, + csvData: { headers, rows: [bigRow] }, + name: 'G', + }, + ]); + prisma.asset.findMany.mockResolvedValueOnce( + Array.from({ length: 30 }, (_, j) => ({ artKey: `art${j}`, s3Key: `k${j}` })) + ); + const res = await request(app) + .post('/api/projects/p1/export-pdf') + .set(authed()) + .send({ dpi: 150 }); + expect(res.status).toBe(200); + expect(s3ExportMocks.putObject).toHaveBeenCalled(); + }); + + it('GET /exports lists', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' } as never); + const res = await request(app).get('/api/projects/p1/exports').set(authed()); + expect(res.status).toBe(200); + expect(res.body.exports.length).toBe(2); + }); + + it('GET /exports 404', async () => { + prisma.project.findFirst.mockResolvedValueOnce(null); + const res = await request(app).get('/api/projects/p1/exports').set(authed()); + expect(res.status).toBe(404); + }); + + it('POST /export-pdf-direct success', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' } as never); + prisma.cardGroup.findMany.mockResolvedValueOnce([ + { + layoutId: 'L1', + layout: { + state: { + version: 2, + width: 100, + height: 100, + root: [ + { + type: 'image', + id: 'i', + x: 0, + y: 0, + width: 10, + height: 10, + artKey: 'a1', + }, + ], + }, + }, + csvData: { headers: ['N'], rows: [{ N: '1' }] }, + name: 'G', + }, + ]); + prisma.asset.findMany.mockResolvedValueOnce([{ artKey: 'a1', s3Key: 'k' }]); + const res = await request(app) + .post('/api/projects/p1/export-pdf-direct') + .set(authed()) + .send({}); + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + }); + + it('POST /export-pdf-direct spawn failure', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' } as never); + prisma.cardGroup.findMany.mockResolvedValueOnce([ + { + layoutId: 'L1', + layout: { + state: { + version: 2, + width: 100, + height: 100, + root: [ + { + type: 'image', + id: 'i', + x: 0, + y: 0, + width: 10, + height: 10, + artKey: 'a1', + }, + ], + }, + }, + csvData: { headers: ['N'], rows: [{ N: '1' }] }, + name: 'G', + }, + ]); + prisma.asset.findMany.mockResolvedValueOnce([{ artKey: 'a1', s3Key: 'k' }]); + vi.mocked(spawnSync).mockReturnValueOnce({ + error: new Error('spawn failed'), + status: null, + stdout: '', + stderr: '', + } as never); + const res = await request(app) + .post('/api/projects/p1/export-pdf-direct') + .set(authed()) + .send({}); + expect(res.status).toBe(502); + }); + + it('POST /export-pdf-direct python stderr with structured error', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' } as never); + prisma.cardGroup.findMany.mockResolvedValueOnce([ + { + layoutId: 'L1', + layout: { + state: { + version: 2, + width: 100, + height: 100, + root: [ + { + type: 'image', + id: 'i', + x: 0, + y: 0, + width: 10, + height: 10, + artKey: 'a1', + }, + ], + }, + }, + csvData: { headers: ['N'], rows: [{ N: '1' }] }, + name: 'G', + }, + ]); + prisma.asset.findMany.mockResolvedValueOnce([{ artKey: 'a1', s3Key: 'k' }]); + vi.mocked(spawnSync).mockReturnValueOnce({ + status: 1, + stdout: JSON.stringify({ ok: false, error: 'bad pdf' }), + stderr: 'warn', + error: undefined, + } as never); + const res = await request(app) + .post('/api/projects/p1/export-pdf-direct') + .set(authed()) + .send({}); + expect(res.status).toBe(502); + expect(res.body.error).toBe('bad pdf'); + }); + + it('POST /export-pdf-direct logs stderr on success', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' } as never); + prisma.cardGroup.findMany.mockResolvedValueOnce([ + { + layoutId: 'L1', + layout: { + state: { + version: 2, + width: 100, + height: 100, + root: [ + { + type: 'image', + id: 'i', + x: 0, + y: 0, + width: 10, + height: 10, + artKey: 'a1', + }, + ], + }, + }, + csvData: { headers: ['N'], rows: [{ N: '1' }] }, + name: 'G', + }, + ]); + prisma.asset.findMany.mockResolvedValueOnce([{ artKey: 'a1', s3Key: 'k' }]); + vi.mocked(spawnSync).mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ ok: true, s3Key: 'out.pdf' }), + stderr: 'python wrote to stderr', + error: undefined, + } as never); + const res = await request(app) + .post('/api/projects/p1/export-pdf-direct') + .set(authed()) + .send({}); + expect(res.status).toBe(200); + }); + + it('POST /export-pdf-direct invalid stdout json', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' } as never); + prisma.cardGroup.findMany.mockResolvedValueOnce([ + { + layoutId: 'L1', + layout: { + state: { + version: 2, + width: 100, + height: 100, + root: [ + { + type: 'image', + id: 'i', + x: 0, + y: 0, + width: 10, + height: 10, + artKey: 'a1', + }, + ], + }, + }, + csvData: { headers: ['N'], rows: [{ N: '1' }] }, + name: 'G', + }, + ]); + prisma.asset.findMany.mockResolvedValueOnce([{ artKey: 'a1', s3Key: 'k' }]); + vi.mocked(spawnSync).mockReturnValueOnce({ + status: 0, + stdout: 'not-json', + stderr: '', + error: undefined, + } as never); + const res = await request(app) + .post('/api/projects/p1/export-pdf-direct') + .set(authed()) + .send({}); + expect(res.status).toBe(502); + }); +}); diff --git a/api/src/routes/projects.test.ts b/api/src/routes/projects.test.ts new file mode 100644 index 0000000..bc127de --- /dev/null +++ b/api/src/routes/projects.test.ts @@ -0,0 +1,864 @@ +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { signToken } from '../lib/jwt.js'; + +vi.mock('../lib/prisma.js', async () => { + const { prisma } = await import('../test/prisma-mock.js'); + return { prisma }; +}); + +import { prisma } from '../test/prisma-mock.js'; +import { createApp } from '../app.js'; + +const app = createApp(); + +function authed() { + const token = signToken({ sub: 'u1', username: 'a@b.com' }); + return { Authorization: `Bearer ${token}` }; +} + +describe('projects routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('GET /api/projects lists', async () => { + prisma.project.findMany.mockResolvedValueOnce([ + { id: 'p1', name: 'P', createdAt: new Date(), updatedAt: new Date() }, + ]); + const res = await request(app).get('/api/projects/').set(authed()); + expect(res.status).toBe(200); + expect(res.body.projects).toHaveLength(1); + }); + + it('POST /api/projects creates', async () => { + prisma.project.create.mockResolvedValueOnce({ + id: 'p1', + name: 'New', + createdAt: new Date(), + updatedAt: new Date(), + }); + const res = await request(app).post('/api/projects/').set(authed()).send({ name: ' New ' }); + expect(res.status).toBe(201); + }); + + it('POST /api/projects 400', async () => { + const res = await request(app).post('/api/projects/').set(authed()).send({ name: '' }); + expect(res.status).toBe(400); + }); + + it('GET layouts 404', async () => { + prisma.project.findFirst.mockResolvedValueOnce(null); + const res = await request(app).get('/api/projects/p1/layouts').set(authed()); + expect(res.status).toBe(404); + }); + + it('GET layouts', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.layout.findMany.mockResolvedValueOnce([ + { id: 'L1', name: 'L', lastUpdated: new Date(), state: {} }, + ]); + const res = await request(app).get('/api/projects/p1/layouts').set(authed()); + expect(res.status).toBe(200); + }); + + it('POST layout 400', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + const res = await request(app) + .post('/api/projects/p1/layouts') + .set(authed()) + .send({ name: '' }); + expect(res.status).toBe(400); + }); + + it('POST layout 400 no state', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + const res = await request(app) + .post('/api/projects/p1/layouts') + .set(authed()) + .send({ name: 'L' }); + expect(res.status).toBe(400); + }); + + it('POST layout 201', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.layout.create.mockResolvedValueOnce({ + id: 'L1', + name: 'L', + lastUpdated: new Date(), + state: { v: 1 }, + }); + const res = await request(app) + .post('/api/projects/p1/layouts') + .set(authed()) + .send({ name: 'L', state: { x: 1 } }); + expect(res.status).toBe(201); + }); + + it('GET single layout 404 project', async () => { + prisma.project.findFirst.mockResolvedValueOnce(null); + const res = await request(app).get('/api/projects/p1/layouts/L1').set(authed()); + expect(res.status).toBe(404); + }); + + it('GET single layout 404 layout', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.layout.findFirst.mockResolvedValueOnce(null); + const res = await request(app).get('/api/projects/p1/layouts/L1').set(authed()); + expect(res.status).toBe(404); + }); + + it('GET single layout', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.layout.findFirst.mockResolvedValueOnce({ + id: 'L1', + name: 'L', + lastUpdated: new Date(), + state: {}, + }); + const res = await request(app).get('/api/projects/p1/layouts/L1').set(authed()); + expect(res.status).toBe(200); + }); + + it('PUT layout', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.layout.findFirst.mockResolvedValueOnce({ id: 'L1' }); + prisma.layout.update.mockResolvedValueOnce({ + id: 'L1', + name: 'N', + lastUpdated: new Date(), + state: {}, + }); + const res = await request(app) + .put('/api/projects/p1/layouts/L1') + .set(authed()) + .send({ name: 'N', state: { a: 1 } }); + expect(res.status).toBe(200); + }); + + it('PUT layout 400 bad name', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.layout.findFirst.mockResolvedValueOnce({ id: 'L1' }); + const res = await request(app) + .put('/api/projects/p1/layouts/L1') + .set(authed()) + .send({ name: ' ' }); + expect(res.status).toBe(400); + }); + + it('DELETE layout', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.layout.findFirst.mockResolvedValueOnce({ id: 'L1' }); + prisma.layout.delete.mockResolvedValueOnce({} as never); + const res = await request(app).delete('/api/projects/p1/layouts/L1').set(authed()); + expect(res.status).toBe(204); + }); + + it('PUT /data 400 bad body', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + const res = await request(app).put('/api/projects/p1/data').set(authed()).send({ x: 1 }); + expect(res.status).toBe(400); + }); + + it('PUT /data', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.project.update.mockResolvedValueOnce({} as never); + prisma.project.findFirst.mockResolvedValueOnce({ + csvData: { headers: ['A'], rows: [{ A: '1' }] }, + csvSourceUrl: null, + }); + const res = await request(app) + .put('/api/projects/p1/data') + .set(authed()) + .send({ + headers: ['A'], + rows: [{ A: '1' }], + }); + expect(res.status).toBe(200); + }); + + it('PUT /csv-link clear', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.project.update.mockResolvedValueOnce({} as never); + const res = await request(app) + .put('/api/projects/p1/csv-link') + .set(authed()) + .send({ url: null }); + expect(res.status).toBe(200); + }); + + it('PUT /csv-link set', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.project.update.mockResolvedValueOnce({} as never); + const res = await request(app) + .put('/api/projects/p1/csv-link') + .set(authed()) + .send({ url: 'https://docs.google.com/spreadsheets/d/x/export?format=csv' }); + expect(res.status).toBe(200); + }); + + it('POST /csv/refresh', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ + id: 'p1', + csvSourceUrl: 'https://example.com/x.csv', + }); + prisma.project.update.mockResolvedValueOnce({} as never); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + text: async () => 'Name\nVal', + headers: { get: () => null }, + }) + ); + const res = await request(app).post('/api/projects/p1/csv/refresh').set(authed()).send({}); + expect(res.status).toBe(200); + }); + + it('POST /csv/refresh 502', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ + id: 'p1', + csvSourceUrl: 'https://example.com/x.csv', + }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Err', + }) + ); + const res = await request(app).post('/api/projects/p1/csv/refresh').set(authed()).send({}); + expect(res.status).toBe(502); + }); + + it('GET card-groups', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findMany.mockResolvedValueOnce([]); + const res = await request(app).get('/api/projects/p1/card-groups').set(authed()); + expect(res.status).toBe(200); + }); + + it('GET card-groups 404 project', async () => { + prisma.project.findFirst.mockResolvedValueOnce(null); + const res = await request(app).get('/api/projects/p1/card-groups').set(authed()); + expect(res.status).toBe(404); + }); + + it('POST card-groups 404 project', async () => { + prisma.project.findFirst.mockResolvedValueOnce(null); + const res = await request(app).post('/api/projects/p1/card-groups').set(authed()).send({}); + expect(res.status).toBe(404); + }); + + it('POST card-group', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.aggregate.mockResolvedValueOnce({ _max: { sortOrder: 0 } }); + prisma.cardGroup.create.mockResolvedValueOnce({ + id: 'g1', + name: 'New group', + layoutId: null, + sortOrder: 1, + csvSourceUrl: null, + dataSourceLabel: null, + csvData: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + const res = await request(app).post('/api/projects/p1/card-groups').set(authed()).send({}); + expect(res.status).toBe(201); + }); + + it('PUT reorder', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findMany.mockResolvedValueOnce([{ id: 'a' }, { id: 'b' }]); + prisma.cardGroup.findMany.mockResolvedValueOnce([]); + const res = await request(app) + .put('/api/projects/p1/card-groups/reorder') + .set(authed()) + .send({ ids: ['a', 'b'] }); + expect(res.status).toBe(200); + }); + + it('PUT reorder 400', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + const res = await request(app) + .put('/api/projects/p1/card-groups/reorder') + .set(authed()) + .send({ ids: [1, 2] }); + expect(res.status).toBe(400); + }); + + it('PUT reorder 400 unknown id', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findMany.mockResolvedValueOnce([{ id: 'a' }]); + const res = await request(app) + .put('/api/projects/p1/card-groups/reorder') + .set(authed()) + .send({ ids: ['a', 'missing'] }); + expect(res.status).toBe(400); + }); + + it('PUT reorder 404 project', async () => { + prisma.project.findFirst.mockResolvedValueOnce(null); + const res = await request(app) + .put('/api/projects/p1/card-groups/reorder') + .set(authed()) + .send({ ids: ['a'] }); + expect(res.status).toBe(404); + }); + + it('PUT group 404 project', async () => { + prisma.project.findFirst.mockResolvedValueOnce(null); + const res = await request(app) + .put('/api/projects/p1/card-groups/g1') + .set(authed()) + .send({ name: 'X' }); + expect(res.status).toBe(404); + }); + + it('PUT group 404 group', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce(null); + const res = await request(app) + .put('/api/projects/p1/card-groups/g1') + .set(authed()) + .send({ name: 'X' }); + expect(res.status).toBe(404); + }); + + it('PUT group rejects blank name', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce({ id: 'g1' }); + const res = await request(app) + .put('/api/projects/p1/card-groups/g1') + .set(authed()) + .send({ name: ' ' }); + expect(res.status).toBe(400); + }); + + it('PUT group rejects malformed csv URL string', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce({ id: 'g1' }); + const res = await request(app) + .put('/api/projects/p1/card-groups/g1') + .set(authed()) + .send({ csvSourceUrl: 'https://[bad' }); + expect(res.status).toBe(400); + }); + + it('PUT group update name', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce({ id: 'g1' }); + prisma.cardGroup.update.mockResolvedValueOnce({ + id: 'g1', + name: 'X', + layoutId: null, + sortOrder: 0, + csvSourceUrl: null, + dataSourceLabel: null, + csvData: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + const res = await request(app) + .put('/api/projects/p1/card-groups/g1') + .set(authed()) + .send({ name: 'X' }); + expect(res.status).toBe(200); + }); + + it('PUT group clear csv', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce({ id: 'g1' }); + prisma.cardGroup.update.mockResolvedValueOnce({ + id: 'g1', + name: 'G', + layoutId: null, + sortOrder: 0, + csvSourceUrl: null, + dataSourceLabel: null, + csvData: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + const res = await request(app) + .put('/api/projects/p1/card-groups/g1') + .set(authed()) + .send({ csvSourceUrl: null }); + expect(res.status).toBe(200); + }); + + it('PUT group fetch csv parses unquoted filename', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce({ id: 'g1' }); + prisma.cardGroup.update.mockResolvedValueOnce({ + id: 'g1', + name: 'G', + layoutId: null, + sortOrder: 0, + csvSourceUrl: 'https://example.com/x.csv', + dataSourceLabel: null, + csvData: { headers: ['N'], rows: [{ N: '1' }] }, + createdAt: new Date(), + updatedAt: new Date(), + }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + text: async () => 'N\n1', + headers: { + get: (h: string) => + h === 'content-disposition' ? 'attachment; filename=Sheet.csv' : null, + }, + }) + ); + const res = await request(app) + .put('/api/projects/p1/card-groups/g1') + .set(authed()) + .send({ csvSourceUrl: 'https://example.com/x.csv' }); + expect(res.status).toBe(200); + }); + + it('PUT group fetch csv uses Content-Disposition filename', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce({ id: 'g1' }); + prisma.cardGroup.update.mockResolvedValueOnce({ + id: 'g1', + name: 'G', + layoutId: null, + sortOrder: 0, + csvSourceUrl: 'https://example.com/x.csv', + dataSourceLabel: 'Tab A', + csvData: { headers: ['N'], rows: [{ N: '1' }] }, + createdAt: new Date(), + updatedAt: new Date(), + }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + text: async () => 'N,v\n1,x', + headers: { + get: (h: string) => + h === 'content-disposition' ? 'attachment; filename="Export - Tab A.csv"' : null, + }, + }) + ); + const res = await request(app) + .put('/api/projects/p1/card-groups/g1') + .set(authed()) + .send({ csvSourceUrl: 'https://example.com/x.csv' }); + expect(res.status).toBe(200); + }); + + it('PUT group fetch csv', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce({ id: 'g1' }); + prisma.cardGroup.update.mockResolvedValueOnce({ + id: 'g1', + name: 'G', + layoutId: null, + sortOrder: 0, + csvSourceUrl: 'https://example.com/x.csv', + dataSourceLabel: 'Tab', + csvData: { headers: ['N'], rows: [{ N: '1' }] }, + createdAt: new Date(), + updatedAt: new Date(), + }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + text: async () => 'Name\nX', + headers: { + get: (h: string) => + h === 'content-disposition' + ? `attachment; filename*=UTF-8''Deck%20-%20MyTab.csv` + : null, + }, + }) + ); + const res = await request(app) + .put('/api/projects/p1/card-groups/g1') + .set(authed()) + .send({ csvSourceUrl: 'https://example.com/x.csv' }); + expect(res.status).toBe(200); + }); + + it('PUT group fetch csv 502 when CSV empty', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce({ id: 'g1' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + text: async () => '', + headers: { get: () => null }, + }) + ); + const res = await request(app) + .put('/api/projects/p1/card-groups/g1') + .set(authed()) + .send({ csvSourceUrl: 'https://example.com/x.csv' }); + expect(res.status).toBe(502); + }); + + it('PUT group fetch csv 502', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce({ id: 'g1' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'N', + }) + ); + const res = await request(app) + .put('/api/projects/p1/card-groups/g1') + .set(authed()) + .send({ csvSourceUrl: 'https://example.com/x.csv' }); + expect(res.status).toBe(502); + }); + + it('POST group csv refresh 404 project', async () => { + prisma.project.findFirst.mockResolvedValueOnce(null); + const res = await request(app) + .post('/api/projects/p1/card-groups/g1/csv/refresh') + .set(authed()) + .send({}); + expect(res.status).toBe(404); + }); + + it('POST group csv refresh 404 group', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce(null); + const res = await request(app) + .post('/api/projects/p1/card-groups/g1/csv/refresh') + .set(authed()) + .send({}); + expect(res.status).toBe(404); + }); + + it('POST group csv refresh no url', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce({ csvSourceUrl: null }); + const res = await request(app) + .post('/api/projects/p1/card-groups/g1/csv/refresh') + .set(authed()) + .send({}); + expect(res.status).toBe(400); + }); + + it('POST group csv refresh invalid stored url', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce({ csvSourceUrl: 'http://insecure.com/x.csv' }); + const res = await request(app) + .post('/api/projects/p1/card-groups/g1/csv/refresh') + .set(authed()) + .send({}); + expect(res.status).toBe(400); + }); + + it('POST group csv refresh download failure 502', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce({ + csvSourceUrl: 'https://example.com/x.csv', + }); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down'))); + const res = await request(app) + .post('/api/projects/p1/card-groups/g1/csv/refresh') + .set(authed()) + .send({}); + expect(res.status).toBe(502); + }); + + it('POST group csv refresh', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce({ + csvSourceUrl: 'https://example.com/x.csv', + }); + prisma.cardGroup.update.mockResolvedValueOnce({ + id: 'g1', + name: 'G', + layoutId: null, + sortOrder: 0, + csvSourceUrl: 'https://example.com/x.csv', + dataSourceLabel: null, + csvData: { headers: ['A'], rows: [] }, + createdAt: new Date(), + updatedAt: new Date(), + }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + text: async () => 'A\n1', + headers: { get: () => null }, + }) + ); + const res = await request(app) + .post('/api/projects/p1/card-groups/g1/csv/refresh') + .set(authed()) + .send({}); + expect(res.status).toBe(200); + }); + + it('duplicate group', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce({ + id: 'g1', + name: 'Long name'.repeat(20), + layoutId: 'L1', + csvSourceUrl: null, + dataSourceLabel: null, + csvData: null, + }); + prisma.cardGroup.aggregate.mockResolvedValueOnce({ _max: { sortOrder: 0 } }); + prisma.cardGroup.create.mockResolvedValueOnce({ + id: 'g2', + name: 'Copy', + layoutId: 'L1', + sortOrder: 1, + csvSourceUrl: null, + dataSourceLabel: null, + csvData: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + const res = await request(app) + .post('/api/projects/p1/card-groups/g1/duplicate') + .set(authed()) + .send({}); + expect(res.status).toBe(201); + }); + + it('delete group', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce({ id: 'g1' }); + prisma.cardGroup.delete.mockResolvedValueOnce({} as never); + const res = await request(app).delete('/api/projects/p1/card-groups/g1').set(authed()); + expect(res.status).toBe(204); + }); + + it('GET project detail', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ + id: 'p1', + name: 'P', + csvData: null, + csvSourceUrl: null, + createdAt: new Date(), + updatedAt: new Date(), + layouts: [], + }); + const res = await request(app).get('/api/projects/p1').set(authed()); + expect(res.status).toBe(200); + }); + + it('PUT project', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.project.update.mockResolvedValueOnce({ + id: 'p1', + name: 'Q', + createdAt: new Date(), + updatedAt: new Date(), + }); + const res = await request(app).put('/api/projects/p1').set(authed()).send({ name: 'Q' }); + expect(res.status).toBe(200); + }); + + it('DELETE project', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.project.delete.mockResolvedValueOnce({} as never); + const res = await request(app).delete('/api/projects/p1').set(authed()); + expect(res.status).toBe(204); + }); + + it('PUT layout connect', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce({ id: 'g1' }); + prisma.layout.findFirst.mockResolvedValueOnce({ id: 'L1' }); + prisma.cardGroup.update.mockResolvedValueOnce({ + id: 'g1', + name: 'G', + layoutId: 'L1', + sortOrder: 0, + csvSourceUrl: null, + dataSourceLabel: null, + csvData: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + const res = await request(app) + .put('/api/projects/p1/card-groups/g1') + .set(authed()) + .send({ layoutId: 'L1' }); + expect(res.status).toBe(200); + }); + + it('PUT layout unknown', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce({ id: 'g1' }); + prisma.layout.findFirst.mockResolvedValueOnce(null); + const res = await request(app) + .put('/api/projects/p1/card-groups/g1') + .set(authed()) + .send({ layoutId: 'bad' }); + expect(res.status).toBe(400); + }); + + it('GET project detail 404', async () => { + prisma.project.findFirst.mockResolvedValueOnce(null); + const res = await request(app).get('/api/projects/p1').set(authed()); + expect(res.status).toBe(404); + }); + + it('PUT project 400 empty name', async () => { + const res = await request(app).put('/api/projects/p1').set(authed()).send({ name: ' ' }); + expect(res.status).toBe(400); + }); + + it('PUT project 404', async () => { + prisma.project.findFirst.mockResolvedValueOnce(null); + const res = await request(app).put('/api/projects/p1').set(authed()).send({ name: 'OK' }); + expect(res.status).toBe(404); + }); + + it('DELETE project 404', async () => { + prisma.project.findFirst.mockResolvedValueOnce(null); + const res = await request(app).delete('/api/projects/p1').set(authed()); + expect(res.status).toBe(404); + }); + + it('PUT /data rejects bad sourceUrl', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + const res = await request(app) + .put('/api/projects/p1/data') + .set(authed()) + .send({ + headers: ['A'], + rows: [{ A: '1' }], + sourceUrl: 'http://insecure.com/x.csv', + }); + expect(res.status).toBe(400); + }); + + it('PUT /data rejects bad sourceUrl type', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + const res = await request(app) + .put('/api/projects/p1/data') + .set(authed()) + .send({ + headers: ['A'], + rows: [{ A: '1' }], + sourceUrl: 123, + }); + expect(res.status).toBe(400); + }); + + it('POST /csv/refresh network failure', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ + id: 'p1', + csvSourceUrl: 'https://example.com/x.csv', + }); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))); + const res = await request(app).post('/api/projects/p1/csv/refresh').set(authed()).send({}); + expect(res.status).toBe(502); + }); + + it('PUT card-group rejects non-string csvSourceUrl', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce({ id: 'g1' }); + const res = await request(app) + .put('/api/projects/p1/card-groups/g1') + .set(authed()) + .send({ csvSourceUrl: 99 }); + expect(res.status).toBe(400); + }); + + it('PUT card-group rejects invalid layoutId type', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce({ id: 'g1' }); + const res = await request(app) + .put('/api/projects/p1/card-groups/g1') + .set(authed()) + .send({ layoutId: 123 }); + expect(res.status).toBe(400); + }); + + it('PUT card-group disconnect layout', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce({ id: 'g1' }); + prisma.cardGroup.update.mockResolvedValueOnce({ + id: 'g1', + name: 'G', + layoutId: null, + sortOrder: 0, + csvSourceUrl: null, + dataSourceLabel: null, + csvData: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + const res = await request(app) + .put('/api/projects/p1/card-groups/g1') + .set(authed()) + .send({ layoutId: null }); + expect(res.status).toBe(200); + }); + + it('duplicate group 404 project', async () => { + prisma.project.findFirst.mockResolvedValueOnce(null); + const res = await request(app) + .post('/api/projects/p1/card-groups/g1/duplicate') + .set(authed()) + .send({}); + expect(res.status).toBe(404); + }); + + it('duplicate group 404 source', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce(null); + const res = await request(app) + .post('/api/projects/p1/card-groups/g1/duplicate') + .set(authed()) + .send({}); + expect(res.status).toBe(404); + }); + + it('delete group 404 project', async () => { + prisma.project.findFirst.mockResolvedValueOnce(null); + const res = await request(app).delete('/api/projects/p1/card-groups/g1').set(authed()); + expect(res.status).toBe(404); + }); + + it('delete group 404 group', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ id: 'p1' }); + prisma.cardGroup.findFirst.mockResolvedValueOnce(null); + const res = await request(app).delete('/api/projects/p1/card-groups/g1').set(authed()); + expect(res.status).toBe(404); + }); + + it('POST csv refresh invalid CSV body', async () => { + prisma.project.findFirst.mockResolvedValueOnce({ + id: 'p1', + csvSourceUrl: 'https://example.com/x.csv', + }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + text: async () => '', + headers: { get: () => null }, + }) + ); + const res = await request(app).post('/api/projects/p1/csv/refresh').set(authed()).send({}); + expect(res.status).toBe(400); + }); +}); diff --git a/api/src/test-setup.ts b/api/src/test-setup.ts new file mode 100644 index 0000000..f3edcec --- /dev/null +++ b/api/src/test-setup.ts @@ -0,0 +1,7 @@ +/** + * Default env for Vitest (matches docker-compose local names when unset). + */ +process.env.JWT_SECRET ??= 'test-jwt-secret-key-minimum-32-characters-long!'; +process.env.S3_BUCKET_ASSETS ??= 'test-assets-bucket'; +process.env.S3_BUCKET_EXPORTS ??= 'test-exports-bucket'; +process.env.SQS_QUEUE_URL ??= 'http://localhost:4566/000000000000/test-queue'; diff --git a/api/src/test/prisma-mock.ts b/api/src/test/prisma-mock.ts new file mode 100644 index 0000000..a75bc91 --- /dev/null +++ b/api/src/test/prisma-mock.ts @@ -0,0 +1,42 @@ +import { vi } from 'vitest'; + +function createMock() { + return { + user: { + findUnique: vi.fn(), + create: vi.fn(), + }, + project: { + findFirst: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + layout: { + findFirst: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + cardGroup: { + findFirst: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + aggregate: vi.fn(), + }, + asset: { + findMany: vi.fn(), + upsert: vi.fn(), + }, + $transaction: vi.fn((ops: unknown) => { + if (Array.isArray(ops)) return Promise.all(ops as Promise[]); + return Promise.resolve(undefined); + }), + }; +} + +export const prisma = createMock(); diff --git a/api/vitest.config.ts b/api/vitest.config.ts index 7eeb3f8..6047730 100644 --- a/api/vitest.config.ts +++ b/api/vitest.config.ts @@ -4,5 +4,23 @@ export default defineConfig({ test: { environment: 'node', include: ['src/**/*.test.ts'], + setupFiles: ['src/test-setup.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + include: ['src/**/*.ts'], + exclude: [ + 'src/**/*.test.ts', + 'src/types/**', + 'src/test-setup.ts', + 'src/test/**', + /** PrismaClient singleton; requires DB — all route tests mock `../lib/prisma.js` */ + 'src/lib/prisma.ts', + /** Thin process bootstrap; exercised in deployment / manual smoke only */ + 'src/index.ts', + /** Production-only SPA fallback + `sendFile`; local tests hit `/health` + `/api/*` only */ + 'src/app.ts', + ], + }, }, }); diff --git a/frontend/.env.local.example b/frontend/.env.local.example index 8e5a077..fa8783c 100644 --- a/frontend/.env.local.example +++ b/frontend/.env.local.example @@ -7,7 +7,13 @@ # Services → cardboardforge-prod-api → Tasks (running) → click task → # "Public IP" under Configuration. # -# No trailing slash. Example: +# No trailing slash. Example (remote API only — not needed for `pnpm dev:local`): # VITE_API_URL=http://54.123.45.67:3001 +# +# For local dev, omit VITE_API_URL entirely so requests stay same-origin and Vite proxies `/api` → localhost:3001. +# A placeholder hostname here will break auth (ERR_NAME_NOT_RESOLVED). -VITE_API_URL=http://YOUR_ECS_TASK_PUBLIC_IP:3001 +# Google Sign-In (OAuth 2.0 Client ID, type “Web application”). Required for “Continue with Google”. +# In Google Cloud Console → APIs & Services → Credentials: add Authorized JavaScript origins +# (e.g. http://localhost:5173 and your production site origin). Optional locally if you only use email/password. +# VITE_GOOGLE_CLIENT_ID=123456789-xxxxxxxx.apps.googleusercontent.com diff --git a/frontend/Dockerfile b/frontend/Dockerfile index f6ef961..da8d8c7 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -9,6 +9,9 @@ COPY frontend/package.json frontend/ RUN pnpm install --frozen-lockfile COPY frontend ./frontend WORKDIR /app/frontend +# Bake OAuth Web client ID into the SPA at build time (same value as local VITE_GOOGLE_CLIENT_ID). +ARG VITE_GOOGLE_CLIENT_ID= +ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID RUN pnpm run build FROM nginx:1.27-alpine AS production diff --git a/frontend/src/App.css b/frontend/src/App.css index cbe4e2b..cf45105 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -179,22 +179,334 @@ body.layout-editor-open .main.main-wide { font: inherit; } -.auth-page { - max-width: 400px; - margin: 48px auto; - padding: 0 16px; - text-align: left; +/* —— Auth / login (full-viewport marketing-style shell) —— */ +.auth-shell { + min-height: 100svh; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + padding: max(20px, env(safe-area-inset-top)) 16px max(28px, env(safe-area-inset-bottom)); + background: + radial-gradient(120% 80% at 50% -10%, rgba(16, 185, 129, 0.09), transparent 52%), + radial-gradient(90% 60% at 100% 50%, rgba(99, 102, 241, 0.06), transparent 45%), + linear-gradient(180deg, #0c0c0f 0%, #0b0b0b 55%, #09090b 100%); } -.auth-page h1 { - margin-top: 0; +.auth-card { + width: 100%; + max-width: 420px; + padding: 32px 28px 28px; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: linear-gradient(165deg, rgba(28, 28, 32, 0.94) 0%, rgba(18, 18, 22, 0.98) 100%); + box-shadow: + var(--shadow), + 0 0 0 1px rgba(255, 255, 255, 0.04) inset, + 0 1px 0 rgba(255, 255, 255, 0.06) inset; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); +} + +@media (max-width: 480px) { + .auth-card { + padding: 28px 20px 24px; + border-radius: 14px; + } +} + +.auth-card-header { + text-align: center; + margin-bottom: 24px; } -.auth-page-brand { - margin: 0 0 4px; +.auth-card-brand { + display: flex; + justify-content: center; + margin-bottom: 18px; line-height: 0; } +.auth-card-title { + margin: 0 0 8px; + font-size: 1.5rem; + font-weight: 600; + letter-spacing: -0.03em; + color: var(--text-h); + line-height: 1.2; +} + +@media (max-width: 480px) { + .auth-card-title { + font-size: 1.35rem; + } +} + +.auth-card-sub { + margin: 0; + font-size: 14px; + line-height: 1.45; + color: var(--text); + opacity: 0.95; +} + +.auth-social { + display: flex; + flex-direction: column; + gap: 10px; +} + +.auth-social-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + padding: 11px 16px; + border-radius: 10px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.04); + color: var(--text-h); + font: inherit; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.01em; + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease, + transform 0.08s ease; +} + +.auth-social-btn:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.14); +} + +.auth-social-btn:active { + transform: scale(0.99); +} + +.auth-social-btn:focus-visible { + outline: 2px solid rgba(16, 185, 129, 0.55); + outline-offset: 2px; +} + +.auth-social-btn--google { + border-color: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.97); + color: #1f1f1f; +} + +.auth-social-btn--google:hover { + background: #ffffff; + border-color: rgba(0, 0, 0, 0.12); +} + +.auth-social-icon { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.auth-social-spinner { + width: 18px; + height: 18px; + flex-shrink: 0; + animation: layout-save-spin 0.75s linear infinite; +} + +.auth-divider { + display: flex; + align-items: center; + gap: 12px; + margin: 22px 0 20px; +} + +.auth-divider-line { + flex: 1; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.12), transparent); +} + +.auth-divider-text { + flex-shrink: 0; + font-size: 12px; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text); + opacity: 0.75; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.auth-banner { + margin: 0; + padding: 10px 12px; + border-radius: 8px; + font-size: 13px; + line-height: 1.45; +} + +.auth-banner--error { + color: #fecaca; + background: rgba(185, 28, 28, 0.2); + border: 1px solid rgba(248, 113, 113, 0.35); +} + +.auth-banner--info { + color: #a7f3d0; + background: var(--accent-bg); + border: 1px solid var(--accent-border); +} + +.auth-field { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + font-size: 14px; +} + +.auth-label, +.auth-label-row .auth-label { + font-size: 13px; + font-weight: 500; + color: var(--text-h); + letter-spacing: 0.01em; +} + +.auth-label-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.auth-inline-link { + flex-shrink: 0; + padding: 0; + border: none; + background: none; + font: inherit; + font-size: 13px; + font-weight: 500; + color: var(--accent); + cursor: pointer; + text-decoration: none; +} + +.auth-inline-link:hover { + text-decoration: underline; +} + +.auth-inline-link:focus-visible { + outline: 2px solid rgba(16, 185, 129, 0.5); + outline-offset: 2px; + border-radius: 4px; +} + +.auth-input { + width: 100%; + box-sizing: border-box; + padding: 11px 12px; + border: 1px solid var(--border); + border-radius: 10px; + font: inherit; + font-size: 15px; + background: rgba(10, 10, 12, 0.65); + color: var(--text-h); + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; +} + +.auth-input:hover { + border-color: rgba(255, 255, 255, 0.14); +} + +.auth-input:focus { + outline: none; + border-color: var(--accent-border); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.18); +} + +.auth-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + margin-top: 4px; + padding: 12px 18px; + border-radius: 10px; + border: 1px solid rgba(16, 185, 129, 0.55); + background: linear-gradient(180deg, rgba(16, 185, 129, 0.35) 0%, rgba(5, 150, 105, 0.45) 100%); + color: #ecfdf5; + font: inherit; + font-size: 15px; + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.12) inset; + transition: + filter 0.15s ease, + opacity 0.15s ease; +} + +.auth-primary:hover:not(:disabled) { + filter: brightness(1.06); +} + +.auth-primary:disabled { + opacity: 0.75; + cursor: wait; +} + +.auth-primary:focus-visible { + outline: 2px solid rgba(16, 185, 129, 0.65); + outline-offset: 2px; +} + +.auth-primary-spinner { + width: 18px; + height: 18px; + animation: layout-save-spin 0.75s linear infinite; +} + +.auth-footer { + margin: 24px 0 0; + text-align: center; + font-size: 14px; + color: var(--text); + line-height: 1.5; +} + +.auth-footer-link { + padding: 0; + border: none; + background: none; + font: inherit; + font-weight: 600; + color: var(--accent); + cursor: pointer; +} + +.auth-footer-link:hover { + text-decoration: underline; +} + +.auth-footer-link:focus-visible { + outline: 2px solid rgba(16, 185, 129, 0.5); + outline-offset: 2px; + border-radius: 4px; +} + .card { display: flex; flex-direction: column; @@ -239,8 +551,8 @@ label.layout-editor-footer-value-strip { font-size: 11px; } -input[type='text']:not(.layout-editor-footer-value-input), -input[type='password'], +input[type='text']:not(.layout-editor-footer-value-input):not(.auth-input), +input[type='password']:not(.auth-input), input[type='number']:not(.layout-editor-footer-value-input), input[type='file'], select, @@ -258,7 +570,7 @@ textarea { min-height: 80px; } -button[type='submit'], +button[type='submit']:not(.auth-primary), .page button[type='button']:not(.link-btn):not(.link-danger):not(.zone-tool):not(.zone-row-body):not( .layout-fullscreen-ghost-btn @@ -1415,23 +1727,6 @@ button.zone-row-body:hover { max-width: 160px; } -.card-group-data-pill { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(255, 255, 255, 0.03); - color: #d4d4d8; - font-size: 12px; - font-weight: 500; -} - -.card-group-data-pill--muted { - color: rgba(161, 161, 170, 0.9); -} - .card-group-synced { font-size: 11px; color: rgba(161, 161, 170, 0.85); @@ -1489,6 +1784,17 @@ button.zone-row-body:hover { background: rgba(0, 0, 0, 0.2); } +.card-group-url-drawer--popover { + border-bottom: none; + background: transparent; + padding: 8px 10px 10px; +} + +.card-group-menu--data-source { + min-width: min(100vw - 32px, 360px); + max-width: min(100vw - 24px, 420px); +} + .card-group-url-drawer-label { display: flex; flex-direction: column; @@ -2544,3 +2850,73 @@ button.zone-tool--icon { button.zone-tool--icon:hover:not(:disabled) { color: var(--accent); } + +/* —— Global toasts (bottom of viewport) —— */ +.toast-viewport { + position: fixed; + bottom: max(16px, env(safe-area-inset-bottom, 0px)); + left: 16px; + right: 16px; + z-index: 10000; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + pointer-events: none; +} + +.toast { + pointer-events: auto; + display: flex; + align-items: flex-start; + gap: 10px; + width: 100%; + max-width: 420px; + padding: 12px 12px 12px 14px; + border-radius: 10px; + font-size: 14px; + line-height: 1.4; + box-shadow: var(--shadow); + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(22, 22, 26, 0.97); + backdrop-filter: blur(10px); + color: var(--text-h); +} + +.toast--error { + border-color: rgba(248, 113, 113, 0.45); + background: rgba(69, 10, 10, 0.92); + color: #fecaca; +} + +.toast--info { + border-color: var(--accent-border); + background: rgba(16, 24, 22, 0.96); + color: #d1fae5; +} + +.toast-message { + flex: 1; + min-width: 0; + word-break: break-word; +} + +.toast-dismiss { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + margin: -4px -4px -4px 0; + padding: 4px; + border: none; + border-radius: 6px; + background: transparent; + color: inherit; + opacity: 0.65; + cursor: pointer; +} + +.toast-dismiss:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.08); +} diff --git a/frontend/src/components/CardFace.tsx b/frontend/src/components/CardFace.tsx index 974821f..7e63af6 100644 --- a/frontend/src/components/CardFace.tsx +++ b/frontend/src/components/CardFace.tsx @@ -1,4 +1,4 @@ -import type { Ref } from 'react'; +import { memo, type Ref } from 'react'; import type { Layer as KonvaLayer } from 'konva/lib/Layer'; import { Group as KonvaGroup, Image as KonvaImage, Layer, Rect, Stage, Text } from 'react-konva'; import type { LayoutElement, LayoutStateV2 } from '../types/layout'; @@ -95,20 +95,26 @@ function CardNode({ return ; } -export function CardFace({ - state, - row, - assetUrls, - pixelWidth, - layerRef, -}: { +function rowDataEqual(a: Record, b: Record): boolean { + if (a === b) return true; + const ak = Object.keys(a); + if (ak.length !== Object.keys(b).length) return false; + for (const k of ak) { + if (a[k] !== b[k]) return false; + } + return true; +} + +type CardFaceProps = { state: LayoutStateV2; row: Record; assetUrls: Record; pixelWidth: number; /** For headless export: observe draw completion */ layerRef?: Ref; -}) { +}; + +function CardFaceInner({ state, row, assetUrls, pixelWidth, layerRef }: CardFaceProps) { const scale = pixelWidth / state.width; const pixelHeight = state.height * scale; const bg = state.background ?? '#1e1e24'; @@ -126,3 +132,14 @@ export function CardFace({ ); } + +export const CardFace = memo(CardFaceInner, (prev, next) => { + return ( + prev.pixelWidth === next.pixelWidth && + prev.state === next.state && + prev.layerRef === next.layerRef && + prev.assetUrls === next.assetUrls && + rowDataEqual(prev.row, next.row) + ); +}); +CardFace.displayName = 'CardFace'; diff --git a/frontend/src/components/CardGroupsPanel.tsx b/frontend/src/components/CardGroupsPanel.tsx index 6171bf4..083456a 100644 --- a/frontend/src/components/CardGroupsPanel.tsx +++ b/frontend/src/components/CardGroupsPanel.tsx @@ -89,8 +89,8 @@ export function CardGroupsPanel(props: { layoutsFull: LayoutFull[]; assetUrls: Record; projectCsvSourceUrl: string | null; + /** Project-wide busy (e.g. layout save); card-group mutations use internal state so previews don’t thrash. */ busy: boolean; - onBusy: (b: boolean) => void; onError: (msg: string | null) => void; onOpenLayoutInEditor: (layoutId: string) => void; }) { @@ -101,16 +101,19 @@ export function CardGroupsPanel(props: { assetUrls, projectCsvSourceUrl, busy, - onBusy, onError, onOpenLayoutInEditor, } = props; const [groups, setGroups] = useState([]); + /** Mutations inside this panel only — avoids lifting `setBusy` to ProjectPage and re-rendering the whole page. */ + const [panelBusy, setPanelBusy] = useState(false); + const opsBusy = busy || panelBusy; const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [editingTitleId, setEditingTitleId] = useState(null); - const [urlEditorGroupId, setUrlEditorGroupId] = useState(null); + /** Which card group has the data source URL popover open (same pattern as layout dropdown). */ + const [dataSourceMenuGroupId, setDataSourceMenuGroupId] = useState(null); const [urlDraft, setUrlDraft] = useState(''); /** Group ids whose gallery is collapsed (default: expanded) */ const [collapsedGalleryIds, setCollapsedGalleryIds] = useState>(() => new Set()); @@ -146,7 +149,7 @@ export function CardGroupsPanel(props: { const createGroup = useCallback(async () => { if (!token) return; - onBusy(true); + setPanelBusy(true); onError(null); try { const res = await apiJson<{ cardGroup: CardGroupDto }>( @@ -158,14 +161,14 @@ export function CardGroupsPanel(props: { } catch (e) { onError(e instanceof Error ? e.message : 'Failed to create group'); } finally { - onBusy(false); + setPanelBusy(false); } - }, [token, projectId, onBusy, onError]); + }, [token, projectId, onError]); const updateGroup = useCallback( async (groupId: string, body: Record) => { if (!token) return; - onBusy(true); + setPanelBusy(true); onError(null); try { const res = await apiJson<{ cardGroup: CardGroupDto }>( @@ -176,17 +179,17 @@ export function CardGroupsPanel(props: { } catch (e) { onError(e instanceof Error ? e.message : 'Update failed'); } finally { - onBusy(false); + setPanelBusy(false); } }, - [token, projectId, onBusy, onError] + [token, projectId, onError] ); const deleteGroup = useCallback( async (groupId: string) => { if (!token) return; if (!window.confirm('Delete this card group?')) return; - onBusy(true); + setPanelBusy(true); onError(null); try { await apiJson(`/api/projects/${projectId}/card-groups/${groupId}`, { @@ -194,20 +197,20 @@ export function CardGroupsPanel(props: { token, }); setGroups((prev) => prev.filter((g) => g.id !== groupId)); - if (urlEditorGroupId === groupId) setUrlEditorGroupId(null); + if (dataSourceMenuGroupId === groupId) setDataSourceMenuGroupId(null); } catch (e) { onError(e instanceof Error ? e.message : 'Delete failed'); } finally { - onBusy(false); + setPanelBusy(false); } }, - [token, projectId, onBusy, onError, urlEditorGroupId] + [token, projectId, onError, dataSourceMenuGroupId] ); const duplicateGroup = useCallback( async (groupId: string) => { if (!token) return; - onBusy(true); + setPanelBusy(true); onError(null); try { const res = await apiJson<{ cardGroup: CardGroupDto }>( @@ -218,16 +221,16 @@ export function CardGroupsPanel(props: { } catch (e) { onError(e instanceof Error ? e.message : 'Duplicate failed'); } finally { - onBusy(false); + setPanelBusy(false); } }, - [token, projectId, onBusy, onError] + [token, projectId, onError] ); const refreshGroupCsv = useCallback( async (groupId: string, url: string | null) => { if (!token || !url?.trim()) return; - onBusy(true); + setPanelBusy(true); onError(null); try { const res = await apiJson<{ cardGroup: CardGroupDto }>( @@ -238,26 +241,26 @@ export function CardGroupsPanel(props: { } catch (e) { onError(e instanceof Error ? e.message : 'Refresh failed'); } finally { - onBusy(false); + setPanelBusy(false); } }, - [token, projectId, onBusy, onError] + [token, projectId, onError] ); - const openUrlEditor = useCallback((g: CardGroupDto) => { - setUrlEditorGroupId(g.id); - setUrlDraft(g.csvSourceUrl ?? ''); - }, []); - - const applyUrlEditor = useCallback( + const saveDataSourceUrl = useCallback( async (groupId: string) => { const trimmed = urlDraft.trim(); await updateGroup(groupId, { csvSourceUrl: trimmed || null }); - setUrlEditorGroupId(null); + setDataSourceMenuGroupId(null); }, [urlDraft, updateGroup] ); + const cancelDataSourceUrl = useCallback(() => { + setDataSourceMenuGroupId(null); + void loadGroups(); + }, [loadGroups]); + const toggleGallery = useCallback((groupId: string) => { setCollapsedGalleryIds((prev) => { const next = new Set(prev); @@ -295,7 +298,7 @@ export function CardGroupsPanel(props: { + ) : null} +
+ + +
+ + +
{ready && g.updatedAt ? ( @@ -450,7 +528,7 @@ export function CardGroupsPanel(props: {
- {urlEditorGroupId === g.id && ( -
- - {projectCsvSourceUrl ? ( - - ) : null} -
- - -
-
- )} -
{ + async (email: string, password: string) => { const data = await apiJson<{ token: string; user: AuthUser }>('/api/auth/login', { method: 'POST', - body: JSON.stringify({ username, password }), + body: JSON.stringify({ email, password }), }); persist(data.token, data.user); }, @@ -27,10 +27,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { ); const register = useCallback( - async (username: string, password: string) => { + async (email: string, password: string) => { const data = await apiJson<{ token: string; user: AuthUser }>('/api/auth/register', { method: 'POST', - body: JSON.stringify({ username, password }), + body: JSON.stringify({ email, password }), + }); + persist(data.token, data.user); + }, + [persist] + ); + + const loginWithGoogle = useCallback( + async (accessToken: string) => { + const data = await apiJson<{ token: string; user: AuthUser }>('/api/auth/google', { + method: 'POST', + body: JSON.stringify({ accessToken }), }); persist(data.token, data.user); }, @@ -43,8 +54,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); const value = useMemo( - () => ({ token, user, login, register, logout }), - [token, user, login, register, logout] + () => ({ token, user, login, register, loginWithGoogle, logout }), + [token, user, login, register, loginWithGoogle, logout] ); return {children}; diff --git a/frontend/src/contexts/ToastProvider.tsx b/frontend/src/contexts/ToastProvider.tsx new file mode 100644 index 0000000..cda404f --- /dev/null +++ b/frontend/src/contexts/ToastProvider.tsx @@ -0,0 +1,68 @@ +import { useCallback, useMemo, useRef, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { X } from 'lucide-react'; +import { ToastContext, type ToastContextValue } from './toast-context'; + +type ToastItem = { id: number; message: string; variant: 'error' | 'info' }; + +const TOAST_MS = 6000; + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + const idRef = useRef(0); + const timersRef = useRef>>(new Map()); + + const remove = useCallback((toastId: number) => { + const t = timersRef.current.get(toastId); + if (t !== undefined) { + window.clearTimeout(t); + timersRef.current.delete(toastId); + } + setToasts((prev) => prev.filter((x) => x.id !== toastId)); + }, []); + + const push = useCallback( + (message: string, variant: 'error' | 'info') => { + const id = ++idRef.current; + setToasts((prev) => [...prev, { id, message, variant }]); + const tid = window.setTimeout(() => remove(id), TOAST_MS); + timersRef.current.set(id, tid); + }, + [remove] + ); + + const showError = useCallback((message: string) => push(message, 'error'), [push]); + const showInfo = useCallback((message: string) => push(message, 'info'), [push]); + + // Stable reference so toast list updates don’t re-render every useToast() consumer. + const value = useMemo(() => ({ showError, showInfo }), [showError, showInfo]); + + return ( + + {children} + {createPortal( +
+ {toasts.map((t) => ( +
+ {t.message} + +
+ ))} +
, + document.body + )} +
+ ); +} diff --git a/frontend/src/contexts/auth-context-value.ts b/frontend/src/contexts/auth-context-value.ts index 508c456..19c73c5 100644 --- a/frontend/src/contexts/auth-context-value.ts +++ b/frontend/src/contexts/auth-context-value.ts @@ -3,7 +3,8 @@ import type { AuthUser } from './auth-types'; export type AuthContextValue = { token: string | null; user: AuthUser | null; - login: (username: string, password: string) => Promise; - register: (username: string, password: string) => Promise; + login: (email: string, password: string) => Promise; + register: (email: string, password: string) => Promise; + loginWithGoogle: (accessToken: string) => Promise; logout: () => void; }; diff --git a/frontend/src/contexts/toast-context.ts b/frontend/src/contexts/toast-context.ts new file mode 100644 index 0000000..1f8bcc2 --- /dev/null +++ b/frontend/src/contexts/toast-context.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react'; + +export type ToastContextValue = { + showError: (message: string) => void; + showInfo: (message: string) => void; +}; + +export const ToastContext = createContext(null); diff --git a/frontend/src/contexts/useToast.ts b/frontend/src/contexts/useToast.ts new file mode 100644 index 0000000..fa94576 --- /dev/null +++ b/frontend/src/contexts/useToast.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { ToastContext, type ToastContextValue } from './toast-context'; + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) { + throw new Error('useToast must be used within ToastProvider'); + } + return ctx; +} diff --git a/frontend/src/lib/loadGsiScript.ts b/frontend/src/lib/loadGsiScript.ts new file mode 100644 index 0000000..17e1e6f --- /dev/null +++ b/frontend/src/lib/loadGsiScript.ts @@ -0,0 +1,51 @@ +const GSI_SRC = 'https://accounts.google.com/gsi/client'; + +let injectPromise: Promise | null = null; + +/** Injects `gsi/client` once and resolves when the script has loaded. */ +function ensureGsiScript(): Promise { + if (typeof window === 'undefined') return Promise.resolve(); + if (window.google?.accounts?.oauth2) return Promise.resolve(); + + if (injectPromise) return injectPromise; + + if (document.querySelector(`script[src="${GSI_SRC}"]`)) { + // Tag already present (e.g. cached); `load` will not fire again — polling below waits for `oauth2`. + injectPromise = Promise.resolve(); + return injectPromise; + } + + injectPromise = new Promise((resolve, reject) => { + const el = document.createElement('script'); + el.src = GSI_SRC; + el.async = true; + el.onload = () => resolve(); + el.onerror = () => { + injectPromise = null; + reject(new Error('Failed to load Google Sign-In script')); + }; + document.head.appendChild(el); + }); + + return injectPromise; +} + +/** Loads the Google Identity Services client once; safe to call in parallel. */ +export async function loadGsiScript(): Promise { + if (typeof window === 'undefined') return; + if (window.google?.accounts?.oauth2) return; + + await ensureGsiScript(); + + // Microtask-only loops starve the browser: the external script cannot run. Poll with `setTimeout` + // so the GSI bundle can execute and attach `google.accounts.oauth2`. + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (window.google?.accounts?.oauth2) return; + await new Promise((r) => { + setTimeout(r, 50); + }); + } + + throw new Error('Google Sign-In did not initialize'); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b9e6db1..8d07307 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,13 +4,16 @@ import { BrowserRouter } from 'react-router-dom'; import './index.css'; import App from './App.tsx'; import { AuthProvider } from './contexts/AuthProvider.tsx'; +import { ToastProvider } from './contexts/ToastProvider.tsx'; createRoot(document.getElementById('root')!).render( - - - + + + + + ); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 495a732..1748d96 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -2,32 +2,31 @@ import { useCallback, useEffect, useState, type FormEvent } from 'react'; import { Link } from 'react-router-dom'; import { apiJson } from '../lib/api'; import { useAuth } from '../contexts/useAuth'; +import { useToast } from '../contexts/useToast'; type Project = { id: string; name: string; createdAt: string; updatedAt: string }; export function DashboardPage() { const { token } = useAuth(); + const { showError } = useToast(); const [projects, setProjects] = useState([]); const [name, setName] = useState(''); - const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const load = useCallback(async () => { if (!token) return; - setError(null); const data = await apiJson<{ projects: Project[] }>('/api/projects', { token }); setProjects(data.projects); }, [token]); useEffect(() => { - void load().catch((e) => setError(e instanceof Error ? e.message : 'Failed to load')); - }, [load]); + void load().catch((e) => showError(e instanceof Error ? e.message : 'Failed to load')); + }, [load, showError]); async function onCreate(e: FormEvent) { e.preventDefault(); if (!token || !name.trim()) return; setBusy(true); - setError(null); try { await apiJson('/api/projects', { method: 'POST', @@ -37,7 +36,7 @@ export function DashboardPage() { setName(''); await load(); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed'); + showError(err instanceof Error ? err.message : 'Failed'); } finally { setBusy(false); } @@ -45,12 +44,11 @@ export function DashboardPage() { async function remove(id: string) { if (!token || !confirm('Delete this project?')) return; - setError(null); try { await apiJson(`/api/projects/${id}`, { method: 'DELETE', token }); await load(); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed'); + showError(err instanceof Error ? err.message : 'Failed'); } } @@ -70,7 +68,6 @@ export function DashboardPage() { - {error &&

{error}

}
    {projects.map((p) => (
  • diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 37535a8..933e4f1 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,83 +1,267 @@ -import { useState, type FormEvent } from 'react'; +import { useId, useState, type FormEvent } from 'react'; import { Navigate } from 'react-router-dom'; +import { Loader2 } from 'lucide-react'; import { BrandLogo } from '../components/BrandLogo'; +import { loadGsiScript } from '../lib/loadGsiScript'; import { useAuth } from '../contexts/useAuth'; +import { useToast } from '../contexts/useToast'; + +function GoogleGlyph({ className }: { className?: string }) { + return ( + + + + + + + ); +} + +function shouldIgnoreGoogleUiError(code: string): boolean { + const c = code.toLowerCase(); + return c.includes('popup') || c.includes('cancel') || c === 'access_denied'; +} export function LoginPage() { - const { user, login, register } = useAuth(); + const { user, login, register, loginWithGoogle } = useAuth(); + const { showError } = useToast(); const [mode, setMode] = useState<'login' | 'register'>('login'); - const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); - const [error, setError] = useState(null); + const [info, setInfo] = useState(null); const [busy, setBusy] = useState(false); + const [googleBusy, setGoogleBusy] = useState(false); + const infoId = useId(); if (user) { return ; } + function clearMessages() { + setInfo(null); + } + async function onSubmit(e: FormEvent) { e.preventDefault(); - setError(null); + clearMessages(); setBusy(true); try { - if (mode === 'login') await login(username, password); - else await register(username, password); + if (mode === 'login') await login(email, password); + else await register(email, password); } catch (err) { - setError(err instanceof Error ? err.message : 'Request failed'); + showError(err instanceof Error ? err.message : 'Request failed'); } finally { setBusy(false); } } + async function onGoogleClick() { + clearMessages(); + const cid = import.meta.env.VITE_GOOGLE_CLIENT_ID?.trim(); + if (!cid) { + showError( + 'Google sign-in is not configured. Set VITE_GOOGLE_CLIENT_ID in your environment (OAuth 2.0 Web client ID).' + ); + return; + } + + setGoogleBusy(true); + try { + await loadGsiScript(); + if (!window.google?.accounts?.oauth2) { + throw new Error('Google Sign-In did not initialize'); + } + + const client = window.google.accounts.oauth2.initTokenClient({ + client_id: cid, + scope: 'openid email profile', + callback: (tokenResponse) => { + void (async () => { + try { + if (tokenResponse.error) { + if (!shouldIgnoreGoogleUiError(tokenResponse.error)) { + showError( + tokenResponse.error_description ?? + tokenResponse.error ?? + 'Google sign-in failed' + ); + } + return; + } + if (!tokenResponse.access_token) return; + await loginWithGoogle(tokenResponse.access_token); + } catch (err) { + showError(err instanceof Error ? err.message : 'Google sign-in failed'); + } finally { + setGoogleBusy(false); + } + })(); + }, + }); + client.requestAccessToken({ prompt: '' }); + } catch (err) { + setGoogleBusy(false); + showError(err instanceof Error ? err.message : 'Could not start Google sign-in'); + } + } + + const heading = mode === 'login' ? 'Welcome back' : 'Create your account'; + const sub = + mode === 'login' + ? 'Sign in to CardGoose to continue to your workspace.' + : 'Set up your credentials to start using CardGoose.'; + return ( -
    -

    - -

    -

    MVP test harness — sign in

    -
    -
    +
    +
    +
    +
    + +
    +

    {heading}

    +

    {sub}

    +
    + +
    +
    + +
    + + or + +
    + + + {info && ( +

    + {info} +

    + )} + + + + + -
    - - - {error &&

    {error}

    } - - + + +

    + {mode === 'login' ? ( + <> + Don't have an account?{' '} + + + ) : ( + <> + Already have an account?{' '} + + + )} +

    +
    ); } diff --git a/frontend/src/pages/ProjectPage.tsx b/frontend/src/pages/ProjectPage.tsx index fa4f8e7..f999c97 100644 --- a/frontend/src/pages/ProjectPage.tsx +++ b/frontend/src/pages/ProjectPage.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } fro import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { apiBase, apiJson } from '../lib/api'; import { useAuth } from '../contexts/useAuth'; +import { useToast } from '../contexts/useToast'; import { parseCsvText } from '../lib/csv'; import { CardGroupsPanel } from '../components/CardGroupsPanel'; import { LayoutsListPanel } from '../components/LayoutsListPanel'; @@ -38,6 +39,7 @@ export function ProjectPage() { const [searchParams, setSearchParams] = useSearchParams(); const { setLayoutEditorChrome, setProjectViewNav } = useStudioChrome(); const { token } = useAuth(); + const { showError } = useToast(); const [tab, setTab] = useState('cards'); const layoutEditorRef = useRef(null); /** Refresh exports list while a queued PDF may still be processing */ @@ -56,7 +58,6 @@ export function ProjectPage() { const [file, setFile] = useState(null); const [csvFile, setCsvFile] = useState(null); const [csvUrlDraft, setCsvUrlDraft] = useState(''); - const [error, setError] = useState(null); const [busy, setBusy] = useState(false); /** Pipeline tab: PDF export bypasses SQS and can take a while */ const [exportPdfLoading, setExportPdfLoading] = useState(false); @@ -118,7 +119,6 @@ export function ProjectPage() { const loadCore = useCallback(async () => { if (!token || !id) return; - setError(null); const [proj, lays] = await Promise.all([ apiJson<{ project: ProjectDetail }>(`/api/projects/${id}`, { token }), apiJson<{ layouts: LayoutFull[] }>(`/api/projects/${id}/layouts`, { token }), @@ -146,8 +146,8 @@ export function ProjectPage() { }, [token, id, loadPipeline, loadCardGroups]); useEffect(() => { - void loadCore().catch((err) => setError(err instanceof Error ? err.message : 'Load failed')); - }, [loadCore]); + void loadCore().catch((err) => showError(err instanceof Error ? err.message : 'Load failed')); + }, [loadCore, showError]); useEffect(() => { if (tab === 'layout') { @@ -160,7 +160,6 @@ export function ProjectPage() { const saveLayout = useCallback(async (): Promise => { if (!token || !id || !activeLayoutId) return false; setBusy(true); - setError(null); try { const { layout } = await apiJson<{ layout: LayoutFull }>( `/api/projects/${id}/layouts/${activeLayoutId}`, @@ -187,12 +186,12 @@ export function ProjectPage() { setLastSavedAt(new Date()); return true; } catch (err) { - setError(err instanceof Error ? err.message : 'Save failed'); + showError(err instanceof Error ? err.message : 'Save failed'); return false; } finally { setBusy(false); } - }, [token, id, activeLayoutId, layoutName, editorState, project]); + }, [token, id, activeLayoutId, layoutName, editorState, project, showError]); const layoutIsDirty = useMemo(() => { if (!savedBaseline) return false; @@ -269,7 +268,6 @@ export function ProjectPage() { async (layoutName: string) => { if (!token || !id) return; setBusy(true); - setError(null); try { const { layout } = await apiJson<{ layout: LayoutFull }>(`/api/projects/${id}/layouts`, { method: 'POST', @@ -287,13 +285,13 @@ export function ProjectPage() { }); } } catch (err) { - setError(err instanceof Error ? err.message : 'Create failed'); + showError(err instanceof Error ? err.message : 'Create failed'); throw err; } finally { setBusy(false); } }, - [token, id, project] + [token, id, project, showError] ); const deleteLayout = useCallback( @@ -307,7 +305,6 @@ export function ProjectPage() { return; } setBusy(true); - setError(null); try { await apiJson(`/api/projects/${id}/layouts/${layoutId}`, { method: 'DELETE', token }); const nextList = layoutsFull.filter((l) => l.id !== layoutId); @@ -349,12 +346,12 @@ export function ProjectPage() { } } } catch (err) { - setError(err instanceof Error ? err.message : 'Delete failed'); + showError(err instanceof Error ? err.message : 'Delete failed'); } finally { setBusy(false); } }, - [token, id, layoutsFull, activeLayoutId, project] + [token, id, layoutsFull, activeLayoutId, project, showError] ); useEffect(() => { @@ -410,7 +407,6 @@ export function ProjectPage() { const name = window.prompt('New layout name', suggested); if (!name?.trim()) return; setBusy(true); - setError(null); try { const { layout } = await apiJson<{ layout: LayoutFull }>(`/api/projects/${id}/layouts`, { method: 'POST', @@ -434,11 +430,11 @@ export function ProjectPage() { }); } } catch (err) { - setError(err instanceof Error ? err.message : 'Save as failed'); + showError(err instanceof Error ? err.message : 'Save as failed'); } finally { setBusy(false); } - }, [token, id, layoutName, editorState, project]); + }, [token, id, layoutName, editorState, project, showError]); const exitLayoutEditor = useCallback(() => { if (layoutIsDirty) { @@ -461,7 +457,6 @@ export function ProjectPage() { e.preventDefault(); if (!token || !id || !csvFile) return; setBusy(true); - setError(null); try { const text = await csvFile.text(); const parsed = parseCsvText(text); @@ -486,7 +481,7 @@ export function ProjectPage() { } setCsvFile(null); } catch (err) { - setError(err instanceof Error ? err.message : 'Import failed'); + showError(err instanceof Error ? err.message : 'Import failed'); } finally { setBusy(false); } @@ -496,7 +491,6 @@ export function ProjectPage() { e.preventDefault(); if (!token || !id || !file) return; setBusy(true); - setError(null); try { const fd = new FormData(); fd.append('file', file); @@ -514,7 +508,7 @@ export function ProjectPage() { setArtKey(''); await loadPipeline(); } catch (err) { - setError(err instanceof Error ? err.message : 'Upload failed'); + showError(err instanceof Error ? err.message : 'Upload failed'); } finally { setBusy(false); } @@ -523,7 +517,6 @@ export function ProjectPage() { async function saveCsvLink() { if (!token || !id) return; setBusy(true); - setError(null); try { const trimmed = csvUrlDraft.trim(); const { csvSourceUrl } = await apiJson<{ csvSourceUrl: string | null }>( @@ -537,7 +530,7 @@ export function ProjectPage() { if (project) setProject({ ...project, csvSourceUrl }); setCsvUrlDraft(csvSourceUrl ?? ''); } catch (err) { - setError(err instanceof Error ? err.message : 'Save link failed'); + showError(err instanceof Error ? err.message : 'Save link failed'); } finally { setBusy(false); } @@ -546,7 +539,6 @@ export function ProjectPage() { async function refreshCsvFromUrl() { if (!token || !id) return; setBusy(true); - setError(null); try { const res = await apiJson<{ csvData: CsvData; csvSourceUrl: string }>( `/api/projects/${id}/csv/refresh`, @@ -567,7 +559,7 @@ export function ProjectPage() { } setCsvUrlDraft(res.csvSourceUrl); } catch (err) { - setError(err instanceof Error ? err.message : 'Refresh failed'); + showError(err instanceof Error ? err.message : 'Refresh failed'); } finally { setBusy(false); } @@ -577,7 +569,6 @@ export function ProjectPage() { if (!token || !id) return; setExportPdfLoading(true); setExportPdfStatus(null); - setError(null); try { await apiJson<{ queued: boolean; projectId: string; timestamp: string }>( `/api/projects/${id}/export-pdf`, @@ -602,11 +593,11 @@ export function ProjectPage() { }, 8000); window.setTimeout(() => setExportPdfStatus(null), 12_000); } catch (err) { - setError(err instanceof Error ? err.message : 'Export failed'); + showError(err instanceof Error ? err.message : 'Export failed'); } finally { setExportPdfLoading(false); } - }, [token, id, loadPipeline, exportPdfDpi]); + }, [token, id, loadPipeline, exportPdfDpi, showError]); useEffect(() => { return () => { @@ -685,8 +676,6 @@ export function ProjectPage() {
    - {error &&

    {error}

    } - {tab === 'cards' && (
    msg && showError(msg)} onOpenLayoutInEditor={openLayoutInEditor} />
    @@ -712,7 +700,7 @@ export function ProjectPage() { lastUpdated: l.lastUpdated, }))} busy={busy} - onError={setError} + onError={(msg) => msg && showError(msg)} onOpenLayout={openLayoutInEditor} onCreateLayout={createLayoutFromList} onDeleteLayout={deleteLayout} diff --git a/frontend/src/types/google-gsi.d.ts b/frontend/src/types/google-gsi.d.ts new file mode 100644 index 0000000..a51fd0b --- /dev/null +++ b/frontend/src/types/google-gsi.d.ts @@ -0,0 +1,27 @@ +export {}; + +type GoogleOAuthTokenResponse = { + access_token?: string; + error?: string; + error_description?: string; +}; + +type GoogleOAuth2TokenClient = { + requestAccessToken: (overrideConfig?: { prompt?: string }) => void; +}; + +declare global { + interface Window { + google?: { + accounts: { + oauth2: { + initTokenClient: (config: { + client_id: string; + scope: string; + callback: (resp: GoogleOAuthTokenResponse) => void; + }) => GoogleOAuth2TokenClient; + }; + }; + }; + } +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index c57d674..9f88aea 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -2,6 +2,8 @@ interface ImportMetaEnv { readonly VITE_API_URL?: string; + /** OAuth 2.0 Web client ID from Google Cloud Console (same app as authorized JavaScript origins). */ + readonly VITE_GOOGLE_CLIENT_ID?: string; } interface ImportMeta { diff --git a/package.json b/package.json index 56a4ccb..22568c6 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "format": "prettier --write \"frontend/**/*.{ts,tsx,js,json,css}\" \"landing/**/*.{ts,js,json,css,html}\" \"api/**/*.{ts,js,json}\"", "format:check": "prettier --check \"frontend/**/*.{ts,tsx,js,json,css}\" \"landing/**/*.{ts,js,json,css,html}\" \"api/**/*.{ts,js,json}\"", "test:all": "pnpm --filter api test && cd worker && PYTHONPATH=src python3 -m pytest", + "test:coverage": "pnpm --filter api test:coverage", "migrate:deploy": "pnpm --filter api prisma:deploy", "migrate:deploy:prod": "pnpm --filter api prisma:deploy:prod", "docker:up": "docker compose up -d postgres localstack", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81024ed..92b6b3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,7 +34,7 @@ importers: version: 3.1024.0 '@prisma/client': specifier: ^6.2.0 - version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) + version: 6.19.3(prisma@6.19.3(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) bcryptjs: specifier: ^2.4.3 version: 2.4.3 @@ -78,6 +78,12 @@ importers: '@types/node': specifier: ^22.10.5 version: 22.19.17 + '@types/supertest': + specifier: ^7.2.0 + version: 7.2.0 + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -89,7 +95,10 @@ importers: version: 9.39.4(jiti@2.6.1) prisma: specifier: ^6.2.0 - version: 6.19.3(typescript@5.9.3) + version: 6.19.3(magicast@0.3.5)(typescript@5.9.3) + supertest: + specifier: ^7.2.2 + version: 7.2.2 tsx: specifier: ^4.19.2 version: 4.21.0 @@ -202,6 +211,10 @@ importers: packages: + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -444,6 +457,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@cloudflare/kv-asset-handler@0.4.2': resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} engines: {node: '>=18.0.0'} @@ -1018,6 +1035,14 @@ packages: cpu: [x64] os: [win32] + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1043,12 +1068,23 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@oxc-project/types@0.122.0': resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -1901,6 +1937,9 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} @@ -1925,6 +1964,9 @@ packages: '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -1967,6 +2009,12 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/superagent@8.1.9': + resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + + '@types/supertest@7.2.0': + resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==} + '@typescript-eslint/eslint-plugin@8.58.0': resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2039,6 +2087,15 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -2089,10 +2146,18 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + append-field@1.0.0: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} @@ -2106,10 +2171,19 @@ packages: array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -2142,6 +2216,9 @@ packages: brace-expansion@1.1.13: resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + brace-expansion@2.0.3: + resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} + brace-expansion@5.0.5: resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} @@ -2225,6 +2302,13 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2258,6 +2342,10 @@ packages: cookie-signature@1.0.7: resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -2266,6 +2354,9 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -2311,6 +2402,10 @@ packages: defu@6.1.6: resolution: {integrity: sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -2329,6 +2424,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + dotenv-cli@8.0.0: resolution: {integrity: sha512-aLqYbK7xKOiTMIRf1lDPbI+Y+Ip/wo5k3eyp6ePysVaSqbyxjyK3dK35BTxG+rmd7djf5q2UPs4noPNH+cj0Qw==} hasBin: true @@ -2345,6 +2443,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -2360,6 +2461,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + empathic@2.0.0: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} @@ -2390,6 +2494,10 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -2499,6 +2607,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-xml-builder@1.1.4: resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} @@ -2534,6 +2645,18 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -2581,6 +2704,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -2604,6 +2732,10 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -2614,6 +2746,9 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -2663,15 +2798,37 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + its-fine@2.0.0: resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==} peerDependencies: react: ^19.0.0 + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2826,6 +2983,9 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2837,6 +2997,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2865,6 +3032,11 @@ packages: engines: {node: '>=4'} hasBin: true + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + miniflare@4.20260409.0: resolution: {integrity: sha512-ayl6To4av0YuXsSivGgWLj+Ug8xZ0Qz3sGV8+Ok2LhNVl6m8m5ktEBM3LX9iT9MtLZRJwBlJrKcraNs/DlZQfA==} engines: {node: '>=18.0.0'} @@ -2877,9 +3049,17 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true @@ -2937,6 +3117,9 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2949,6 +3132,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2969,6 +3155,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@0.1.13: resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} @@ -3244,6 +3434,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -3273,6 +3467,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -3280,6 +3478,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -3290,6 +3492,14 @@ packages: strnum@2.2.2: resolution: {integrity: sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==} + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -3309,6 +3519,10 @@ packages: resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} engines: {node: '>=6'} + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + thread-stream@4.0.0: resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} engines: {node: '>=20'} @@ -3590,6 +3804,13 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -3642,6 +3863,11 @@ packages: snapshots: + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -4259,6 +4485,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@1.0.2': {} + '@cloudflare/kv-asset-handler@0.4.2': {} '@cloudflare/unenv-preset@2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260409.1)': @@ -4628,6 +4856,17 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.3': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4659,10 +4898,19 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@noble/hashes@1.8.0': {} + '@oxc-project/types@0.122.0': {} + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@poppinss/colors@4.1.6': dependencies: kleur: 4.1.5 @@ -4675,14 +4923,14 @@ snapshots: '@poppinss/exception@1.2.3': {} - '@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)': + '@prisma/client@6.19.3(prisma@6.19.3(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)': optionalDependencies: - prisma: 6.19.3(typescript@5.9.3) + prisma: 6.19.3(magicast@0.3.5)(typescript@5.9.3) typescript: 5.9.3 - '@prisma/config@6.19.3': + '@prisma/config@6.19.3(magicast@0.3.5)': dependencies: - c12: 3.1.0 + c12: 3.1.0(magicast@0.3.5) deepmerge-ts: 7.1.5 effect: 3.21.0 empathic: 2.0.0 @@ -5506,6 +5754,8 @@ snapshots: dependencies: '@types/node': 24.12.2 + '@types/cookiejar@2.1.5': {} + '@types/cors@2.8.19': dependencies: '@types/node': 24.12.2 @@ -5536,6 +5786,8 @@ snapshots: '@types/ms': 2.1.0 '@types/node': 24.12.2 + '@types/methods@1.1.4': {} + '@types/ms@2.1.0': {} '@types/multer@1.4.13': @@ -5579,6 +5831,18 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 24.12.2 + '@types/superagent@8.1.9': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 24.12.2 + form-data: 4.0.5 + + '@types/supertest@7.2.0': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.9 + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5675,6 +5939,25 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.12 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -5737,10 +6020,14 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: {} + append-field@1.0.0: {} argparse@2.0.1: {} @@ -5751,8 +6038,18 @@ snapshots: array-flatten@1.1.1: {} + asap@2.0.6: {} + assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} balanced-match@1.0.2: {} @@ -5789,6 +6086,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.0.3: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 @@ -5811,7 +6112,7 @@ snapshots: bytes@3.1.2: {} - c12@3.1.0: + c12@3.1.0(magicast@0.3.5): dependencies: chokidar: 4.0.3 confbox: 0.2.4 @@ -5825,6 +6126,8 @@ snapshots: perfect-debounce: 1.0.0 pkg-types: 2.3.0 rc9: 2.1.2 + optionalDependencies: + magicast: 0.3.5 cac@6.7.14: {} @@ -5879,6 +6182,12 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + component-emitter@1.3.1: {} + concat-map@0.0.1: {} concat-stream@1.6.2: @@ -5911,10 +6220,14 @@ snapshots: cookie-signature@1.0.7: {} + cookie-signature@1.2.2: {} + cookie@0.7.2: {} cookie@1.1.1: {} + cookiejar@2.1.4: {} + core-util-is@1.0.3: {} cors@2.8.6: @@ -5946,6 +6259,8 @@ snapshots: defu@6.1.6: {} + delayed-stream@1.0.0: {} + depd@2.0.0: {} destr@2.0.5: {} @@ -5956,6 +6271,11 @@ snapshots: detect-node-es@1.1.0: {} + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + dotenv-cli@8.0.0: dependencies: cross-spawn: 7.0.6 @@ -5973,6 +6293,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -5988,6 +6310,8 @@ snapshots: emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + empathic@2.0.0: {} encodeurl@2.0.0: {} @@ -6009,6 +6333,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -6214,6 +6545,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} + fast-xml-builder@1.1.4: dependencies: path-expression-matcher: 1.2.1 @@ -6256,6 +6589,25 @@ snapshots: flatted@3.4.2: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + forwarded@0.2.0: {} fresh@0.5.2: {} @@ -6306,6 +6658,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + globals@14.0.0: {} globals@17.4.0: {} @@ -6318,6 +6679,10 @@ snapshots: has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -6328,6 +6693,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-escaper@2.0.2: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -6367,6 +6734,27 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + its-fine@2.0.0(@types/react@19.2.14)(react@19.2.4): dependencies: '@types/react-reconciler': 0.28.9(@types/react@19.2.14) @@ -6374,8 +6762,16 @@ snapshots: transitivePeerDependencies: - '@types/react' + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jiti@2.6.1: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -6502,6 +6898,8 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -6514,6 +6912,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + math-intrinsics@1.1.0: {} media-typer@0.3.0: {} @@ -6530,6 +6938,8 @@ snapshots: mime@1.6.0: {} + mime@2.6.0: {} + miniflare@4.20260409.0: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -6550,8 +6960,14 @@ snapshots: dependencies: brace-expansion: 1.1.13 + minimatch@9.0.9: + dependencies: + brace-expansion: 2.0.3 + minimist@1.2.8: {} + minipass@7.1.3: {} + mkdirp@0.5.6: dependencies: minimist: 1.2.8 @@ -6598,6 +7014,10 @@ snapshots: dependencies: ee-first: 1.1.1 + once@1.4.0: + dependencies: + wrappy: 1.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -6615,6 +7035,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -6627,6 +7049,11 @@ snapshots: path-key@3.1.1: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-to-regexp@0.1.13: {} path-to-regexp@6.3.0: {} @@ -6684,9 +7111,9 @@ snapshots: prettier@3.8.1: {} - prisma@6.19.3(typescript@5.9.3): + prisma@6.19.3(magicast@0.3.5)(typescript@5.9.3): dependencies: - '@prisma/config': 6.19.3 + '@prisma/config': 6.19.3(magicast@0.3.5) '@prisma/engines': 6.19.3 optionalDependencies: typescript: 5.9.3 @@ -6984,6 +7411,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@4.1.0: {} + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -7006,6 +7435,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -7014,6 +7449,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-json-comments@3.1.1: {} strip-literal@3.1.0: @@ -7022,6 +7461,28 @@ snapshots: strnum@2.2.2: {} + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.14.2 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + supports-color@10.2.2: {} supports-color@7.2.0: @@ -7036,6 +7497,12 @@ snapshots: tapable@2.3.2: {} + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.5.0 + minimatch: 10.2.5 + thread-stream@4.0.0: dependencies: real-require: 0.2.0 @@ -7276,6 +7743,14 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + ws@8.18.0: {} xtend@4.0.2: {}