Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions apps/api/sql/003_surveys.sql
Original file line number Diff line number Diff line change
@@ -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);
3 changes: 2 additions & 1 deletion apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
})
Expand Down
80 changes: 76 additions & 4 deletions apps/api/src/db/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -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) {
Expand Down
143 changes: 143 additions & 0 deletions apps/api/src/db/worksight.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import type {
AttendanceRecord,
AttendanceStats,
EmployeeProfile,
Survey,
SurveyQuestion,
SurveyResponseMetadata,
SurveySubmission,
Team,
} from '@worksight/common';
import { DatabaseService } from './database.service';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -175,6 +211,74 @@ export class WorksightRepository {
averageHours: Math.round(average * 100) / 100,
};
}

async listSurveys(): Promise<Survey[]> {
const { rows } = await this.db.query<SurveyRow>(`SELECT * FROM surveys ORDER BY created_at`);
return rows.map(toSurvey);
}

async getSurvey(id: string): Promise<Survey | null> {
const { rows } = await this.db.query<SurveyRow>(`SELECT * FROM surveys WHERE id = $1`, [id]);
return rows[0] ? toSurvey(rows[0]) : null;
}

async listSurveyQuestions(surveyId: string): Promise<SurveyQuestion[]> {
const { rows } = await this.db.query<SurveyQuestionRow>(
`SELECT * FROM survey_questions WHERE survey_id = $1 ORDER BY id`,
[surveyId]
);
return rows.map(toSurveyQuestion);
}

async listSurveySubmissions(employeeId?: string): Promise<SurveyResponseMetadata[]> {
const { rows } = employeeId
? await this.db.query<SurveyResponseMetaRow>(
`SELECT * FROM survey_response_meta WHERE employee_id = $1 ORDER BY submitted_at DESC`,
[employeeId]
)
: await this.db.query<SurveyResponseMetaRow>(
`SELECT * FROM survey_response_meta ORDER BY submitted_at DESC`
);
return rows.map(toSurveyResponseMeta);
}

async createSurveySubmission(
surveyId: string,
submission: SurveySubmission
): Promise<SurveyResponseMetadata> {
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<SurveyResponseMetaRow>(
`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 {
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading