From 934c2f0239a03996eb1ae6c570e40e30f4bfeb43 Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:49:54 +0800 Subject: [PATCH] feat(api): survey endpoints with validated submissions Surveys are the product's burnout signal but nothing served them and the fixtures could not enter a database (SURVEY-123 / EMP-001 / RESP-001-Q* placeholder ids). Repair the fixtures, tighten created_by and employee_id to z.uuid(), and add sql/003_surveys.sql plus seed support. Expose GET /surveys, GET /surveys/:id/questions, GET /surveys/responses and POST /surveys/:id/responses. Submissions are validated at the boundary with a new SurveySubmissionSchema in @worksight/common (the API has no direct zod dependency); avg_score is the mean of numeric answers, matching the existing fixture value. DB mode writes meta + answers in a transaction; fixture mode keeps submissions in memory so the offline demo can still submit. Co-authored-by: Cursor --- apps/api/sql/003_surveys.sql | 54 +++++++ apps/api/src/app.module.ts | 3 +- apps/api/src/db/seed.ts | 80 ++++++++++- apps/api/src/db/worksight.repository.ts | 143 +++++++++++++++++++ apps/api/src/surveys/surveys.controller.ts | 47 ++++++ apps/api/src/surveys/surveys.module.ts | 9 ++ apps/api/src/surveys/surveys.service.spec.ts | 55 +++++++ apps/api/src/surveys/surveys.service.ts | 81 +++++++++++ docs/handoffs/2026-07-26-api-postgres.md | 18 ++- packages/common/src/data/survey.ts | 22 +-- packages/common/src/types/survey.ts | 26 +++- 11 files changed, 517 insertions(+), 21 deletions(-) create mode 100644 apps/api/sql/003_surveys.sql create mode 100644 apps/api/src/surveys/surveys.controller.ts create mode 100644 apps/api/src/surveys/surveys.module.ts create mode 100644 apps/api/src/surveys/surveys.service.spec.ts create mode 100644 apps/api/src/surveys/surveys.service.ts diff --git a/apps/api/sql/003_surveys.sql b/apps/api/sql/003_surveys.sql new file mode 100644 index 0000000..c9f700c --- /dev/null +++ b/apps/api/sql/003_surveys.sql @@ -0,0 +1,54 @@ +-- WorkSight survey schema. Aligns with @worksight/common Survey / +-- SurveyQuestion / SurveyResponseMetadata / SurveyResponse. Apply after +-- 001_core.sql: +-- psql "$DATABASE_URL" -f apps/api/sql/003_surveys.sql + +CREATE TABLE IF NOT EXISTS surveys ( + id UUID PRIMARY KEY, + created_by UUID NOT NULL REFERENCES employees(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + num_questions INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS survey_questions ( + survey_id UUID NOT NULL REFERENCES surveys(id) ON DELETE CASCADE, + id INTEGER NOT NULL, + question_text TEXT NOT NULL, + question_subtext TEXT, + -- SurveyQuestion.dimension is string | string[]; stored uniformly as an array. + dimension TEXT[] NOT NULL DEFAULT '{}', + type TEXT NOT NULL + CHECK (type IN ('scale','text','radio','number','email')), + required BOOLEAN NOT NULL DEFAULT true, + options TEXT[], + reverse_score BOOLEAN NOT NULL DEFAULT false, + min_value DOUBLE PRECISION, + min_label TEXT, + max_value DOUBLE PRECISION, + max_label TEXT, + default_value JSONB, + PRIMARY KEY (survey_id, id) +); + +CREATE TABLE IF NOT EXISTS survey_response_meta ( + id UUID PRIMARY KEY, + survey_id UUID NOT NULL REFERENCES surveys(id) ON DELETE CASCADE, + employee_id UUID NOT NULL REFERENCES employees(id) ON DELETE CASCADE, + submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + avg_score DOUBLE PRECISION +); + +CREATE INDEX IF NOT EXISTS survey_response_meta_employee_id_idx + ON survey_response_meta (employee_id); + +CREATE TABLE IF NOT EXISTS survey_responses ( + id UUID PRIMARY KEY, + response_meta_id UUID NOT NULL REFERENCES survey_response_meta(id) ON DELETE CASCADE, + question_id INTEGER NOT NULL, + -- string | number | null over the wire; JSONB keeps the type intact. + response JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS survey_responses_meta_idx + ON survey_responses (response_meta_id); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 1a329ad..8c67fda 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -3,11 +3,12 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AttendanceModule } from './attendance/attendance.module'; import { DatabaseModule } from './db/database.module'; +import { SurveysModule } from './surveys/surveys.module'; import { TasksModule } from './tasks/tasks.module'; import { UsersModule } from './users/users.module'; @Module({ - imports: [DatabaseModule, UsersModule, TasksModule, AttendanceModule], + imports: [DatabaseModule, UsersModule, TasksModule, AttendanceModule, SurveysModule], controllers: [AppController], providers: [AppService], }) diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts index 4922d2f..023148d 100644 --- a/apps/api/src/db/seed.ts +++ b/apps/api/src/db/seed.ts @@ -5,7 +5,17 @@ * * Idempotent: truncates core tables then reloads. Requires 001_core.sql applied. */ -import { Activities, Assignments, Attendance, Employees, Teams } from '@worksight/common'; +import { + Activities, + Assignments, + Attendance, + Employees, + SurveyQuestionnaire, + SurveyResponseList, + SurveyResponses, + Surveys, + Teams, +} from '@worksight/common'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { Pool } from 'pg'; @@ -20,14 +30,16 @@ async function main() { const client = await pool.connect(); try { - for (const file of ['001_core.sql', '002_attendance.sql']) { + for (const file of ['001_core.sql', '002_attendance.sql', '003_surveys.sql']) { const schemaSql = readFileSync(join(__dirname, '..', '..', 'sql', file), 'utf8'); await client.query(schemaSql); } await client.query('BEGIN'); await client.query( - `TRUNCATE attendance, activities, assignments, teams, employees RESTART IDENTITY CASCADE` + `TRUNCATE survey_responses, survey_response_meta, survey_questions, surveys, + attendance, activities, assignments, teams, employees + RESTART IDENTITY CASCADE` ); // Insert managers before reports so manager_id FKs resolve. @@ -142,6 +154,62 @@ async function main() { ); } + for (const survey of Surveys) { + await client.query( + `INSERT INTO surveys (id, created_by, created_at, num_questions) + VALUES ($1,$2,$3,$4)`, + [survey.id, survey.created_by, survey.created_at, survey.num_questions] + ); + } + + for (const question of SurveyQuestionnaire) { + await client.query( + `INSERT INTO survey_questions + (survey_id, id, question_text, question_subtext, dimension, type, + required, options, reverse_score, min_value, min_label, max_value, + max_label, default_value) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, + [ + question.survey_id, + question.id, + question.question_text, + question.question_subtext ?? null, + Array.isArray(question.dimension) ? question.dimension : [question.dimension], + question.type, + question.required, + question.options ?? null, + question.reverseScore, + question.min_value ?? null, + question.min_label ?? null, + question.max_value ?? null, + question.max_label ?? null, + question.defaultValue === undefined ? null : JSON.stringify(question.defaultValue), + ] + ); + } + + for (const meta of SurveyResponseList) { + await client.query( + `INSERT INTO survey_response_meta (id, survey_id, employee_id, submitted_at, avg_score) + VALUES ($1,$2,$3,$4,$5)`, + [meta.id, meta.survey_id, meta.employee_id, meta.submitted_at, meta.avg_score] + ); + } + + for (const response of SurveyResponses) { + await client.query( + `INSERT INTO survey_responses (id, response_meta_id, question_id, response, created_at) + VALUES ($1,$2,$3,$4,$5)`, + [ + response.id, + response.response_meta_id, + response.question_id, + response.response === null ? null : JSON.stringify(response.response), + response.created_at, + ] + ); + } + await client.query('COMMIT'); const counts = await client.query( @@ -150,7 +218,11 @@ async function main() { (SELECT count(*)::int FROM teams) AS teams, (SELECT count(*)::int FROM assignments) AS assignments, (SELECT count(*)::int FROM activities) AS activities, - (SELECT count(*)::int FROM attendance) AS attendance` + (SELECT count(*)::int FROM attendance) AS attendance, + (SELECT count(*)::int FROM surveys) AS surveys, + (SELECT count(*)::int FROM survey_questions) AS survey_questions, + (SELECT count(*)::int FROM survey_response_meta) AS survey_submissions, + (SELECT count(*)::int FROM survey_responses) AS survey_responses` ); console.log('Seeded', counts.rows[0]); } catch (error) { diff --git a/apps/api/src/db/worksight.repository.ts b/apps/api/src/db/worksight.repository.ts index c8bacd9..e5c4ca7 100644 --- a/apps/api/src/db/worksight.repository.ts +++ b/apps/api/src/db/worksight.repository.ts @@ -5,6 +5,10 @@ import type { AttendanceRecord, AttendanceStats, EmployeeProfile, + Survey, + SurveyQuestion, + SurveyResponseMetadata, + SurveySubmission, Team, } from '@worksight/common'; import { DatabaseService } from './database.service'; @@ -68,6 +72,38 @@ type AttendanceStatsRow = { days_present: number; }; +type SurveyRow = { + id: string; + created_by: string; + created_at: Date; + num_questions: number; +}; + +type SurveyQuestionRow = { + survey_id: string; + id: number; + question_text: string; + question_subtext: string | null; + dimension: string[]; + type: SurveyQuestion['type']; + required: boolean; + options: string[] | null; + reverse_score: boolean; + min_value: number | null; + min_label: string | null; + max_value: number | null; + max_label: string | null; + default_value: string | number | null; +}; + +type SurveyResponseMetaRow = { + id: string; + survey_id: string; + employee_id: string; + submitted_at: Date; + avg_score: number | null; +}; + type ActivityRow = { id: string; source_id: string; @@ -175,6 +211,74 @@ export class WorksightRepository { averageHours: Math.round(average * 100) / 100, }; } + + async listSurveys(): Promise { + const { rows } = await this.db.query(`SELECT * FROM surveys ORDER BY created_at`); + return rows.map(toSurvey); + } + + async getSurvey(id: string): Promise { + const { rows } = await this.db.query(`SELECT * FROM surveys WHERE id = $1`, [id]); + return rows[0] ? toSurvey(rows[0]) : null; + } + + async listSurveyQuestions(surveyId: string): Promise { + const { rows } = await this.db.query( + `SELECT * FROM survey_questions WHERE survey_id = $1 ORDER BY id`, + [surveyId] + ); + return rows.map(toSurveyQuestion); + } + + async listSurveySubmissions(employeeId?: string): Promise { + const { rows } = employeeId + ? await this.db.query( + `SELECT * FROM survey_response_meta WHERE employee_id = $1 ORDER BY submitted_at DESC`, + [employeeId] + ) + : await this.db.query( + `SELECT * FROM survey_response_meta ORDER BY submitted_at DESC` + ); + return rows.map(toSurveyResponseMeta); + } + + async createSurveySubmission( + surveyId: string, + submission: SurveySubmission + ): Promise { + const numeric = submission.answers + .map(a => a.response) + .filter((r): r is number => typeof r === 'number'); + const avgScore = numeric.length + ? Math.round((numeric.reduce((a, b) => a + b, 0) / numeric.length) * 100) / 100 + : null; + + return this.db.withClient(async client => { + await client.query('BEGIN'); + try { + const { + rows: [meta], + } = await client.query( + `INSERT INTO survey_response_meta (id, survey_id, employee_id, submitted_at, avg_score) + VALUES (gen_random_uuid(), $1, $2, now(), $3) + RETURNING *`, + [surveyId, submission.employee_id, avgScore] + ); + for (const answer of submission.answers) { + await client.query( + `INSERT INTO survey_responses (id, response_meta_id, question_id, response) + VALUES (gen_random_uuid(), $1, $2, $3)`, + [meta.id, answer.question_id, answer.response === null ? null : JSON.stringify(answer.response)] + ); + } + await client.query('COMMIT'); + return toSurveyResponseMeta(meta); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } + }); + } } function toEmployee(row: EmployeeRow): EmployeeProfile { @@ -237,6 +341,45 @@ function toAttendance(row: AttendanceRow): AttendanceRecord { }; } +function toSurvey(row: SurveyRow): Survey { + return { + id: row.id, + created_by: row.created_by, + created_at: new Date(row.created_at), + num_questions: row.num_questions, + }; +} + +function toSurveyQuestion(row: SurveyQuestionRow): SurveyQuestion { + return { + id: row.id, + survey_id: row.survey_id, + question_text: row.question_text, + question_subtext: row.question_subtext ?? undefined, + // Stored as an array; unwrap singletons for fixture parity. + dimension: row.dimension.length === 1 ? row.dimension[0] : row.dimension, + type: row.type, + required: row.required, + options: row.options ?? undefined, + reverseScore: row.reverse_score, + min_value: row.min_value ?? undefined, + min_label: row.min_label ?? undefined, + max_value: row.max_value ?? undefined, + max_label: row.max_label ?? undefined, + defaultValue: row.default_value ?? undefined, + }; +} + +function toSurveyResponseMeta(row: SurveyResponseMetaRow): SurveyResponseMetadata { + return { + id: row.id, + survey_id: row.survey_id, + employee_id: row.employee_id, + submitted_at: new Date(row.submitted_at), + avg_score: row.avg_score, + }; +} + function toActivity(row: ActivityRow): Activity { return { id: row.id, diff --git a/apps/api/src/surveys/surveys.controller.ts b/apps/api/src/surveys/surveys.controller.ts new file mode 100644 index 0000000..7d35f18 --- /dev/null +++ b/apps/api/src/surveys/surveys.controller.ts @@ -0,0 +1,47 @@ +import { + BadRequestException, + Body, + Controller, + Get, + HttpCode, + Param, + Post, + Query, +} from '@nestjs/common'; +import { + SurveySubmissionSchema, + type Survey, + type SurveyQuestion, + type SurveyResponseMetadata, +} from '@worksight/common'; +import { SurveysService } from './surveys.service'; + +@Controller('surveys') +export class SurveysController { + constructor(private readonly surveysService: SurveysService) {} + + @Get() + getAll(): Promise { + return this.surveysService.findAll(); + } + + @Get('responses') + getSubmissions(@Query('employee_id') employeeId?: string): Promise { + return this.surveysService.findSubmissions(employeeId); + } + + @Get(':id/questions') + getQuestions(@Param('id') id: string): Promise { + return this.surveysService.findQuestions(id); + } + + @Post(':id/responses') + @HttpCode(201) + submit(@Param('id') id: string, @Body() body: unknown): Promise { + const parsed = SurveySubmissionSchema.safeParse(body); + if (!parsed.success) { + throw new BadRequestException(parsed.error.issues); + } + return this.surveysService.submit(id, parsed.data); + } +} diff --git a/apps/api/src/surveys/surveys.module.ts b/apps/api/src/surveys/surveys.module.ts new file mode 100644 index 0000000..9b5bdb9 --- /dev/null +++ b/apps/api/src/surveys/surveys.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { SurveysController } from './surveys.controller'; +import { SurveysService } from './surveys.service'; + +@Module({ + controllers: [SurveysController], + providers: [SurveysService], +}) +export class SurveysModule {} diff --git a/apps/api/src/surveys/surveys.service.spec.ts b/apps/api/src/surveys/surveys.service.spec.ts new file mode 100644 index 0000000..a6ae98a --- /dev/null +++ b/apps/api/src/surveys/surveys.service.spec.ts @@ -0,0 +1,55 @@ +import { NotFoundException } from '@nestjs/common'; +import { SurveyQuestionnaire, SurveyResponseList, Surveys } from '@worksight/common'; +import type { WorksightRepository } from '../db/worksight.repository'; +import { SurveysService } from './surveys.service'; + +const SURVEY_ID = Surveys[0].id; +const EMPLOYEE_ID = SurveyResponseList[0].employee_id; + +describe('SurveysService', () => { + let service: SurveysService; + + beforeEach(() => { + const fixturesOnly = { enabled: false } as WorksightRepository; + service = new SurveysService(fixturesOnly); + }); + + it('returns the shared survey templates', async () => { + await expect(service.findAll()).resolves.toEqual(Surveys); + }); + + it('returns the questionnaire for a survey', async () => { + const questions = await service.findQuestions(SURVEY_ID); + expect(questions).toHaveLength(SurveyQuestionnaire.length); + }); + + it('rejects unknown surveys', async () => { + await expect( + service.findQuestions('00000000-0000-4000-8000-000000000000') + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('lists submissions, optionally by employee', async () => { + await expect(service.findSubmissions()).resolves.toEqual(SurveyResponseList); + await expect(service.findSubmissions(EMPLOYEE_ID)).resolves.toEqual(SurveyResponseList); + await expect( + service.findSubmissions('00000000-0000-4000-8000-000000000000') + ).resolves.toEqual([]); + }); + + it('accepts a submission and averages numeric answers', async () => { + const meta = await service.submit(SURVEY_ID, { + employee_id: EMPLOYEE_ID, + answers: [ + { question_id: 0, response: 4 }, + { question_id: 1, response: 2 }, + { question_id: 9, response: 'free text is ignored by the average' }, + ], + }); + expect(meta.survey_id).toBe(SURVEY_ID); + expect(meta.avg_score).toBe(3); + + const mine = await service.findSubmissions(EMPLOYEE_ID); + expect(mine).toContainEqual(meta); + }); +}); diff --git a/apps/api/src/surveys/surveys.service.ts b/apps/api/src/surveys/surveys.service.ts new file mode 100644 index 0000000..1c97c95 --- /dev/null +++ b/apps/api/src/surveys/surveys.service.ts @@ -0,0 +1,81 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { + SurveyMetadataLookup, + SurveyQuestionLookup, + SurveyResponseList, + type Survey, + type SurveyQuestion, + type SurveyResponseMetadata, + type SurveySubmission, +} from '@worksight/common'; +import { WorksightRepository } from '../db/worksight.repository'; + +@Injectable() +export class SurveysService { + private readonly surveys = new SurveyMetadataLookup(); + private readonly questions = new SurveyQuestionLookup(); + // Fixture-mode submissions live in memory for the offline demo. + private readonly submissions: SurveyResponseMetadata[] = [...SurveyResponseList]; + + constructor(private readonly repo: WorksightRepository) {} + + async findAll(): Promise { + if (this.repo.enabled) { + return this.repo.listSurveys(); + } + return this.surveys.all(); + } + + async findQuestions(surveyId: string): Promise { + if (this.repo.enabled) { + const survey = await this.repo.getSurvey(surveyId); + if (!survey) { + throw new NotFoundException(`Survey ${surveyId} not found`); + } + return this.repo.listSurveyQuestions(surveyId); + } + if (!this.surveys.filter({ id: surveyId }).first()) { + throw new NotFoundException(`Survey ${surveyId} not found`); + } + return this.questions.filter({ survey_id: surveyId }).all(); + } + + async findSubmissions(employeeId?: string): Promise { + if (this.repo.enabled) { + return this.repo.listSurveySubmissions(employeeId); + } + if (employeeId) { + return this.submissions.filter(s => s.employee_id === employeeId); + } + return [...this.submissions]; + } + + async submit(surveyId: string, submission: SurveySubmission): Promise { + if (this.repo.enabled) { + const survey = await this.repo.getSurvey(surveyId); + if (!survey) { + throw new NotFoundException(`Survey ${surveyId} not found`); + } + return this.repo.createSurveySubmission(surveyId, submission); + } + + if (!this.surveys.filter({ id: surveyId }).first()) { + throw new NotFoundException(`Survey ${surveyId} not found`); + } + const numeric = submission.answers + .map(a => a.response) + .filter((r): r is number => typeof r === 'number'); + const meta: SurveyResponseMetadata = { + id: randomUUID(), + survey_id: surveyId, + employee_id: submission.employee_id, + submitted_at: new Date(), + avg_score: numeric.length + ? Math.round((numeric.reduce((a, b) => a + b, 0) / numeric.length) * 100) / 100 + : null, + }; + this.submissions.push(meta); + return meta; + } +} diff --git a/docs/handoffs/2026-07-26-api-postgres.md b/docs/handoffs/2026-07-26-api-postgres.md index b19749e..5fa27dc 100644 --- a/docs/handoffs/2026-07-26-api-postgres.md +++ b/docs/handoffs/2026-07-26-api-postgres.md @@ -73,8 +73,22 @@ the same fixtures the offline demo uses. - `EmployeeProfile.manager_id` is nullable; `Assignment.employee_id` / `source_id` are UUIDs. -Survey fixtures still contain invalid UUIDs — they are out of the API surface -for now. +## Surveys (follow-up branch `feat/api-surveys`) + +- Survey fixtures repaired: `SURVEY-123` → the real survey UUID, `EMP-001` → + E001's UUID, `RESP-001-Q*` ids → deterministic UUIDs (`5e590000-…`). + `SurveySchema.created_by` and `SurveyResponseMetadataSchema.employee_id` + are now `z.uuid()`. No fixture dataset with invalid UUIDs remains. +- `apps/api/sql/003_surveys.sql` — `surveys`, `survey_questions` (dimension + stored as `TEXT[]`, `default_value`/`response` as JSONB), + `survey_response_meta`, `survey_responses`. Seeded on Neon: 1 survey, + 25 questions, 1 submission, 9 answers. +- Endpoints: `GET /surveys`, `GET /surveys/:id/questions`, + `GET /surveys/responses` (optional `?employee_id=`), and + `POST /surveys/:id/responses` validated with the new + `SurveySubmissionSchema` from `@worksight/common`. avg_score is the mean of + numeric answers (2 dp), matching the fixture value. Fixture mode keeps + submissions in memory so the offline demo can still submit. ## Attendance (follow-up branch `feat/api-attendance`) diff --git a/packages/common/src/data/survey.ts b/packages/common/src/data/survey.ts index b52c483..76b41c7 100644 --- a/packages/common/src/data/survey.ts +++ b/packages/common/src/data/survey.ts @@ -347,8 +347,8 @@ export const Surveys: Survey[] = [ export const SurveyResponseList: SurveyResponseMetadata[] = [ { id: 'd80ae2c8-dcc3-42cd-96d0-c1eb63ca795d', - survey_id: 'SURVEY-123', - employee_id: 'EMP-001', + survey_id: '277068ac-b7a9-45b5-9d41-f66b017509c7', + employee_id: '08b6fc43-77e6-4fcf-8ed8-dafc16b4b025', submitted_at: new Date('2025-09-15T08:00:00.000Z'), avg_score: 3.57, }, @@ -356,63 +356,63 @@ export const SurveyResponseList: SurveyResponseMetadata[] = [ export const SurveyResponses: SurveyResponse[] = [ { - id: 'RESP-001-Q1', + id: '5e590000-0000-4000-8000-000000000001', response_meta_id: 'd80ae2c8-dcc3-42cd-96d0-c1eb63ca795d', question_id: 1, response: 4, created_at: new Date('2025-09-15T08:00:00.000Z'), }, { - id: 'RESP-001-Q2', + id: '5e590000-0000-4000-8000-000000000002', response_meta_id: 'd80ae2c8-dcc3-42cd-96d0-c1eb63ca795d', question_id: 2, response: 3, created_at: new Date('2025-09-15T08:00:00.000Z'), }, { - id: 'RESP-001-Q3', + id: '5e590000-0000-4000-8000-000000000003', response_meta_id: 'd80ae2c8-dcc3-42cd-96d0-c1eb63ca795d', question_id: 3, response: 4, created_at: new Date('2025-09-15T08:00:00.000Z'), }, { - id: 'RESP-001-Q4', + id: '5e590000-0000-4000-8000-000000000004', response_meta_id: 'd80ae2c8-dcc3-42cd-96d0-c1eb63ca795d', question_id: 4, response: 2, created_at: new Date('2025-09-15T08:00:00.000Z'), }, { - id: 'RESP-001-Q5', + id: '5e590000-0000-4000-8000-000000000005', response_meta_id: 'd80ae2c8-dcc3-42cd-96d0-c1eb63ca795d', question_id: 5, response: 5, created_at: new Date('2025-09-15T08:00:00.000Z'), }, { - id: 'RESP-001-Q6', + id: '5e590000-0000-4000-8000-000000000006', response_meta_id: 'd80ae2c8-dcc3-42cd-96d0-c1eb63ca795d', question_id: 6, response: 3, created_at: new Date('2025-09-15T08:00:00.000Z'), }, { - id: 'RESP-001-Q7', + id: '5e590000-0000-4000-8000-000000000007', response_meta_id: 'd80ae2c8-dcc3-42cd-96d0-c1eb63ca795d', question_id: 7, response: 4, created_at: new Date('2025-09-15T08:00:00.000Z'), }, { - id: 'RESP-001-Q9', + id: '5e590000-0000-4000-8000-000000000009', response_meta_id: 'd80ae2c8-dcc3-42cd-96d0-c1eb63ca795d', question_id: 9, response: 'Juggling too many tasks at once.', created_at: new Date('2025-09-15T08:00:00.000Z'), }, { - id: 'RESP-001-Q10', + id: '5e590000-0000-4000-8000-000000000010', response_meta_id: 'd80ae2c8-dcc3-42cd-96d0-c1eb63ca795d', question_id: 10, response: 'Weekly sync could be shorter and more focused.', diff --git a/packages/common/src/types/survey.ts b/packages/common/src/types/survey.ts index b474d98..436d05a 100644 --- a/packages/common/src/types/survey.ts +++ b/packages/common/src/types/survey.ts @@ -25,8 +25,8 @@ export const SurveyQuestionTypeSchema = z.enum([ export const SurveySchema = z.object({ /** Unique survey ID */ id: z.uuid(), - /** Creator's identifier */ - created_by: z.string(), + /** Creator's employee id */ + created_by: z.uuid(), /** Creation timestamp */ created_at: z.date(), /** Number of questions in the survey */ @@ -67,7 +67,7 @@ export const SurveyQuestionSchema = z.object({ export const SurveyResponseMetadataSchema = z.object({ id: z.uuid(), survey_id: z.uuid(), - employee_id: z.string(), + employee_id: z.uuid(), submitted_at: z.date(), avg_score: z.number().nullable(), }); @@ -87,6 +87,25 @@ export const SurveyResponseSchema = z.object({ created_at: z.date(), }); +/** ----------------------------- */ +/** Survey Submission (API input) */ +/** ----------------------------- */ + +/** + * Payload for submitting a filled survey. + */ +export const SurveySubmissionSchema = z.object({ + employee_id: z.uuid(), + answers: z + .array( + z.object({ + question_id: z.number().int().nonnegative(), + response: z.union([z.string(), z.number()]).nullable(), + }) + ) + .min(1), +}); + /** ----------------------------- */ /** Survey Question Stats */ /** ----------------------------- */ @@ -166,6 +185,7 @@ export type SurveyQuestion = z.infer; export type SurveyQuestionType = z.infer; export type SurveyQuestionStats = z.infer; export type SurveyResponse = z.infer; +export type SurveySubmission = z.infer; export type SurveyResponseStats = z.infer; export type SurveyResponseMetadata = z.infer; export type SurveyResponseMetadataStats = z.infer;