From 57809985fdc7b371488283b9f93b2b7caf4b2e03 Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 17 Jun 2026 15:29:14 +0200 Subject: [PATCH] feat(api): add activities + work_sessions domain schema and shared contracts --- apps/api/drizzle/0001_common_leopardon.sql | 26 + apps/api/drizzle/meta/0001_snapshot.json | 550 +++++++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 42 ++ apps/api/test/schema.test.ts | 60 +++ packages/shared/src/index.ts | 47 ++ 6 files changed, 732 insertions(+) create mode 100644 apps/api/drizzle/0001_common_leopardon.sql create mode 100644 apps/api/drizzle/meta/0001_snapshot.json create mode 100644 apps/api/test/schema.test.ts diff --git a/apps/api/drizzle/0001_common_leopardon.sql b/apps/api/drizzle/0001_common_leopardon.sql new file mode 100644 index 0000000..21d417d --- /dev/null +++ b/apps/api/drizzle/0001_common_leopardon.sql @@ -0,0 +1,26 @@ +CREATE TABLE `activities` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `insole_types` text DEFAULT '["Kurk","Berk","3D"]' NOT NULL, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL +); +--> statement-breakpoint +CREATE TABLE `work_sessions` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `activity_id` integer NOT NULL, + `insole_type` text, + `pair_count` integer DEFAULT 2 NOT NULL, + `start_time` integer NOT NULL, + `end_time` integer, + `duration_seconds` integer, + `status` text DEFAULT 'active' NOT NULL, + `source` text DEFAULT 'app' NOT NULL, + `notes` text, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`activity_id`) REFERENCES `activities`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `work_sessions_userId_idx` ON `work_sessions` (`user_id`);--> statement-breakpoint +CREATE INDEX `work_sessions_startTime_idx` ON `work_sessions` (`start_time`); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0001_snapshot.json b/apps/api/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..fc29549 --- /dev/null +++ b/apps/api/drizzle/meta/0001_snapshot.json @@ -0,0 +1,550 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a878be91-939d-44a7-8aa7-546bb6ff8b6f", + "prevId": "e8545186-09fa-4515-bca1-891ed8364a07", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "activities": { + "name": "activities", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insole_types": { + "name": "insole_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"Kurk\",\"Berk\",\"3D\"]'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "work_sessions": { + "name": "work_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "activity_id": { + "name": "activity_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "insole_type": { + "name": "insole_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pair_count": { + "name": "pair_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2 + }, + "start_time": { + "name": "start_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'app'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "work_sessions_userId_idx": { + "name": "work_sessions_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "work_sessions_startTime_idx": { + "name": "work_sessions_startTime_idx", + "columns": [ + "start_time" + ], + "isUnique": false + } + }, + "foreignKeys": { + "work_sessions_user_id_user_id_fk": { + "name": "work_sessions_user_id_user_id_fk", + "tableFrom": "work_sessions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "work_sessions_activity_id_activities_id_fk": { + "name": "work_sessions_activity_id_activities_id_fk", + "tableFrom": "work_sessions", + "tableTo": "activities", + "columnsFrom": [ + "activity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index ef57a15..f90dec7 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1781697895207, "tag": "0000_stiff_captain_britain", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1781702878767, + "tag": "0001_common_leopardon", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 3225bb0..61e0f55 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -109,3 +109,45 @@ export const accountRelations = relations(account, ({ one }) => ({ references: [user.id], }), })); + +// ---- SoleLog domain tables (Phase 1) ---- +export const activities = sqliteTable('activities', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + // subset of 'Kurk' | 'Berk' | '3D' — stored as a JSON string by libsql. + insoleTypes: text('insole_types', { mode: 'json' }) + .$type() + .notNull() + .default(['Kurk', 'Berk', '3D']), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), +}); + +export const workSessions = sqliteTable( + 'work_sessions', + { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + activityId: integer('activity_id') + .notNull() + .references(() => activities.id), + insoleType: text('insole_type'), + pairCount: integer('pair_count').notNull().default(2), + startTime: integer('start_time', { mode: 'timestamp_ms' }).notNull(), + endTime: integer('end_time', { mode: 'timestamp_ms' }), // null = active + durationSeconds: integer('duration_seconds'), + status: text('status').notNull().default('active'), // 'active' | 'completed' | 'discarded' + source: text('source').notNull().default('app'), // 'app' | 'manual' + notes: text('notes'), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + }, + (table) => ({ + workSessionsUserIdIdx: index('work_sessions_userId_idx').on(table.userId), + workSessionsStartTimeIdx: index('work_sessions_startTime_idx').on(table.startTime), + }) +); diff --git a/apps/api/test/schema.test.ts b/apps/api/test/schema.test.ts new file mode 100644 index 0000000..65d3aba --- /dev/null +++ b/apps/api/test/schema.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { eq } from 'drizzle-orm'; +import { db } from '../src/db/client'; +import { activities, workSessions, user } from '../src/db/schema'; +import { createApp } from '../src/app'; + +const json = { 'content-type': 'application/json' }; + +describe('domain schema', () => { + it('creates and reads back an activity with a json insole_types array', async () => { + const [inserted] = await db + .insert(activities) + .values({ name: 'Frezen', insoleTypes: ['Kurk', 'Berk'] }) + .returning(); + + const [row] = await db.select().from(activities).where(eq(activities.id, inserted.id)); + + expect(row.name).toBe('Frezen'); + expect(row.insoleTypes).toEqual(['Kurk', 'Berk']); + }); + + it('defaults a work_sessions row to status=active, source=app, pair_count=2, null end_time', async () => { + const app = createApp(); + const creds = { + email: 'schema-user@example.com', + password: 'sterk-wachtwoord-123', + name: 'Schema User', + }; + await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: json, + body: JSON.stringify(creds), + }); + + const [createdUser] = await db.select().from(user).where(eq(user.email, creds.email)); + expect(createdUser).toBeTruthy(); + + const [activity] = await db + .insert(activities) + .values({ name: 'Slijpen', insoleTypes: ['Kurk', 'Berk', '3D'] }) + .returning(); + + const [inserted] = await db + .insert(workSessions) + .values({ + userId: createdUser.id, + activityId: activity.id, + startTime: new Date(), + }) + .returning(); + + const [row] = await db.select().from(workSessions).where(eq(workSessions.id, inserted.id)); + + expect(row.status).toBe('active'); + expect(row.source).toBe('app'); + expect(row.pairCount).toBe(2); + expect(row.endTime).toBeNull(); + expect(row.durationSeconds).toBeNull(); + }); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 55bd6b0..227521b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -16,3 +16,50 @@ export const MeResponse = z.object({ user: PublicUser, }); export type MeResponse = z.infer; + +export const InsoleType = z.enum(['Kurk', 'Berk', '3D']); +export type InsoleType = z.infer; + +export const Activity = z.object({ + id: z.number().int(), + name: z.string(), + insole_types: z.array(InsoleType), + created_at: z.string(), // ISO-8601 +}); +export type Activity = z.infer; + +export const CreateActivityInput = z.object({ + name: z.string().trim().min(1), + insole_types: z.array(InsoleType).default(['Kurk', 'Berk', '3D']), +}); +export type CreateActivityInput = z.infer; + +export const UpdateActivityInput = CreateActivityInput; +export type UpdateActivityInput = z.infer; + +export const SessionStatus = z.enum(['active', 'completed', 'discarded']); +export type SessionStatus = z.infer; + +export const WorkSession = z.object({ + id: z.number().int(), + user_id: z.string(), + activity_id: z.number().int(), + activity_name: z.string().optional(), // present on history/active joins + insole_type: InsoleType.nullable(), + pair_count: z.number().int(), + start_time: z.string(), // ISO-8601 + end_time: z.string().nullable(), + duration_seconds: z.number().int().nullable(), + status: SessionStatus, + source: z.enum(['app', 'manual']), + notes: z.string().nullable(), + created_at: z.string(), +}); +export type WorkSession = z.infer; + +export const StartSessionInput = z.object({ + activity_id: z.number().int(), + insole_type: InsoleType, + pair_count: z.number().int().min(1).default(2), +}); +export type StartSessionInput = z.infer;