From 0d82b6efbca6caaaf799b47ab472655533bea6c9 Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 17 Jun 2026 20:49:56 +0200 Subject: [PATCH] feat(shared,api): add pause + sort_order columns and contracts Adds server-side pause accounting and activity ordering primitives. - WorkSession contract gains paused_seconds (number) and paused_at (ISO string | null). - Activity contract gains sort_order (number); new ReorderActivitiesInput zod. - work_sessions += paused_seconds (int NOT NULL DEFAULT 0) + paused_at (timestamp_ms nullable). - activities += sort_order (int NOT NULL DEFAULT 0). - toWorkSession / toActivity map the new fields; generated migration 0003. The new fields are additive; existing api/worker/admin tests stay green. Products affected: SoleLog backend (apps/api), shared contracts (packages/shared) --- apps/api/drizzle/0003_sharp_giant_girl.sql | 3 + apps/api/drizzle/meta/0003_snapshot.json | 608 +++++++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 3 + apps/api/src/lib/work-session.ts | 2 + apps/api/src/routes/activities.ts | 1 + apps/api/test/work-session.test.ts | 39 ++ packages/shared/src/index.ts | 8 + 8 files changed, 671 insertions(+) create mode 100644 apps/api/drizzle/0003_sharp_giant_girl.sql create mode 100644 apps/api/drizzle/meta/0003_snapshot.json create mode 100644 apps/api/test/work-session.test.ts diff --git a/apps/api/drizzle/0003_sharp_giant_girl.sql b/apps/api/drizzle/0003_sharp_giant_girl.sql new file mode 100644 index 0000000..5b5a8aa --- /dev/null +++ b/apps/api/drizzle/0003_sharp_giant_girl.sql @@ -0,0 +1,3 @@ +ALTER TABLE `activities` ADD `sort_order` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `work_sessions` ADD `paused_seconds` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `work_sessions` ADD `paused_at` integer; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0003_snapshot.json b/apps/api/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..7ce1aef --- /dev/null +++ b/apps/api/drizzle/meta/0003_snapshot.json @@ -0,0 +1,608 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f74073b0-c7a9-4329-9b48-69e927e79fc5", + "prevId": "3d48e08d-2ae7-4987-bb60-e5199726c129", + "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\"]'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "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 + }, + "impersonated_by": { + "name": "impersonated_by", + "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 + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banned": { + "name": "banned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "integer", + "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 + }, + "paused_seconds": { + "name": "paused_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "paused_at": { + "name": "paused_at", + "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 10400aa..43eec93 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1781710299706, "tag": "0002_solid_prodigy", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1781722003307, + "tag": "0003_sharp_giant_girl", + "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 d1d2d48..ffe1c49 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -124,6 +124,7 @@ export const activities = sqliteTable('activities', { .$type() .notNull() .default(['Kurk', 'Berk', '3D']), + sortOrder: integer('sort_order').notNull().default(0), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .notNull(), @@ -144,6 +145,8 @@ export const workSessions = sqliteTable( startTime: integer('start_time', { mode: 'timestamp_ms' }).notNull(), endTime: integer('end_time', { mode: 'timestamp_ms' }), // null = active durationSeconds: integer('duration_seconds'), + pausedSeconds: integer('paused_seconds').notNull().default(0), + pausedAt: integer('paused_at', { mode: 'timestamp_ms' }), // null = running status: text('status').notNull().default('active'), // 'active' | 'completed' | 'discarded' source: text('source').notNull().default('app'), // 'app' | 'manual' notes: text('notes'), diff --git a/apps/api/src/lib/work-session.ts b/apps/api/src/lib/work-session.ts index 94116a7..e8c4b69 100644 --- a/apps/api/src/lib/work-session.ts +++ b/apps/api/src/lib/work-session.ts @@ -19,6 +19,8 @@ export function toWorkSession( start_time: new Date(row.startTime).toISOString(), end_time: row.endTime ? new Date(row.endTime).toISOString() : null, duration_seconds: row.durationSeconds ?? null, + paused_seconds: row.pausedSeconds ?? 0, + paused_at: row.pausedAt ? new Date(row.pausedAt).toISOString() : null, status: row.status as WorkSession['status'], source: row.source as WorkSession['source'], notes: row.notes ?? null, diff --git a/apps/api/src/routes/activities.ts b/apps/api/src/routes/activities.ts index b6eb38b..7fd31a7 100644 --- a/apps/api/src/routes/activities.ts +++ b/apps/api/src/routes/activities.ts @@ -16,6 +16,7 @@ function toActivity(row: ActivityRow): Activity { name: row.name, insole_types: row.insoleTypes as Activity['insole_types'], created_at: new Date(row.createdAt).toISOString(), + sort_order: row.sortOrder ?? 0, }; } diff --git a/apps/api/test/work-session.test.ts b/apps/api/test/work-session.test.ts new file mode 100644 index 0000000..f899d47 --- /dev/null +++ b/apps/api/test/work-session.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { toWorkSession } from '../src/lib/work-session'; +import type { workSessions } from '../src/db/schema'; + +type WorkSessionRow = typeof workSessions.$inferSelect; + +function baseRow(overrides: Partial = {}): WorkSessionRow { + return { + id: 1, + userId: 'user-1', + activityId: 1, + insoleType: 'Kurk', + pairCount: 2, + startTime: new Date('2026-06-17T08:00:00.000Z'), + endTime: null, + durationSeconds: null, + pausedSeconds: 0, + pausedAt: null, + status: 'active', + source: 'app', + notes: null, + createdAt: new Date('2026-06-17T08:00:00.000Z'), + ...overrides, + }; +} + +describe('toWorkSession paused fields', () => { + it('maps pausedSeconds and a null pausedAt', () => { + const result = toWorkSession(baseRow({ pausedSeconds: 120, pausedAt: null })); + expect(result.paused_seconds).toBe(120); + expect(result.paused_at).toBeNull(); + }); + + it('maps a pausedAt Date to its ISO string', () => { + const pausedAt = new Date('2026-06-17T08:05:00.000Z'); + const result = toWorkSession(baseRow({ pausedSeconds: 0, pausedAt })); + expect(result.paused_at).toBe(pausedAt.toISOString()); + }); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 86401c2..5ad66c0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -29,6 +29,7 @@ export const Activity = z.object({ name: z.string(), insole_types: z.array(InsoleType), created_at: z.string(), // ISO-8601 + sort_order: z.number().int(), }); export type Activity = z.infer; @@ -41,6 +42,11 @@ export type CreateActivityInput = z.infer; export const UpdateActivityInput = CreateActivityInput; export type UpdateActivityInput = z.infer; +export const ReorderActivitiesInput = z.object({ + ids: z.array(z.number().int()).min(1), +}); +export type ReorderActivitiesInput = z.infer; + export const SessionStatus = z.enum(['active', 'completed', 'discarded']); export type SessionStatus = z.infer; @@ -56,6 +62,8 @@ export const WorkSession = z.object({ start_time: z.string(), // ISO-8601 end_time: z.string().nullable(), duration_seconds: z.number().int().nullable(), + paused_seconds: z.number().int(), + paused_at: z.string().nullable(), // ISO-8601; set while paused, null = running status: SessionStatus, source: z.enum(['app', 'manual']), notes: z.string().nullable(),