feat(api): add activities + work_sessions domain schema and shared contracts

This commit is contained in:
Bas van Rossem
2026-06-17 15:29:14 +02:00
parent 40a2512dfd
commit 57809985fd
6 changed files with 732 additions and 0 deletions

View File

@@ -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`);

View File

@@ -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": {}
}
}

View File

@@ -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
}
]
}

View File

@@ -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<string[]>()
.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),
})
);

View File

@@ -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();
});
});