diff --git a/lib/db/repos/audit_stub.js b/lib/db/repos/audit_stub.js new file mode 100644 index 0000000..1f8ad54 --- /dev/null +++ b/lib/db/repos/audit_stub.js @@ -0,0 +1,2 @@ +// Replaced in Task 16 with real audit_log writes. +export async function recordAudit() { /* noop until 006 migration lands */ } diff --git a/lib/db/repos/projects.js b/lib/db/repos/projects.js new file mode 100644 index 0000000..aeb129f --- /dev/null +++ b/lib/db/repos/projects.js @@ -0,0 +1,50 @@ +import { pool } from '../pool.js'; +import { recordAudit } from './audit_stub.js'; + +export async function create({ space_id, slug, name, description, status = 'active', started_at }, actor) { + const { rows: [r] } = await pool.query( + `INSERT INTO projects(space_id, slug, name, description, status, started_at) + VALUES($1,$2,$3,$4,$5,$6) RETURNING *`, + [space_id, slug, name, description || null, status, started_at || null] + ); + await recordAudit(actor, 'create', 'project', r.id, null, r); + return r; +} + +export async function getById(id) { + const { rows: [r] } = await pool.query(`SELECT * FROM projects WHERE id=$1`, [id]); + return r; +} + +export async function listBySpace(space_id, { status } = {}) { + const sql = status + ? `SELECT * FROM projects WHERE space_id=$1 AND status=$2 ORDER BY name` + : `SELECT * FROM projects WHERE space_id=$1 ORDER BY name`; + const args = status ? [space_id, status] : [space_id]; + const { rows } = await pool.query(sql, args); + return rows; +} + +export async function update(id, patch, actor) { + const before = await getById(id); + const fields = ['slug','name','description','status','started_at','completed_at']; + const sets = [], vals = []; + let i = 1; + for (const f of fields) { + if (patch[f] !== undefined) { sets.push(`${f}=$${i++}`); vals.push(patch[f]); } + } + sets.push(`updated_at=now()`); + vals.push(id); + const { rows: [r] } = await pool.query( + `UPDATE projects SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`, + vals + ); + await recordAudit(actor, 'update', 'project', id, before, r); + return r; +} + +export async function del(id, actor) { + const before = await getById(id); + await pool.query(`DELETE FROM projects WHERE id=$1`, [id]); + await recordAudit(actor, 'delete', 'project', id, before, null); +} diff --git a/lib/db/repos/spaces.js b/lib/db/repos/spaces.js new file mode 100644 index 0000000..1620625 --- /dev/null +++ b/lib/db/repos/spaces.js @@ -0,0 +1,53 @@ +import { pool } from '../pool.js'; +import { recordAudit } from './audit_stub.js'; + +export async function create({ slug, name, description, theme }, actor) { + const { rows: [r] } = await pool.query( + `INSERT INTO spaces(slug, name, description, theme) + VALUES($1,$2,$3,$4) RETURNING *`, + [slug, name, description || null, theme || null] + ); + await recordAudit(actor, 'create', 'space', r.id, null, r); + return r; +} + +export async function getById(id) { + const { rows: [r] } = await pool.query( + `SELECT * FROM spaces WHERE id=$1`, [id]); + return r; +} + +export async function getBySlug(slug) { + const { rows: [r] } = await pool.query( + `SELECT * FROM spaces WHERE slug=$1`, [slug]); + return r; +} + +export async function list() { + const { rows } = await pool.query(`SELECT * FROM spaces ORDER BY name`); + return rows; +} + +export async function update(id, patch, actor) { + const before = await getById(id); + const fields = ['name','description','theme','slug']; + const sets = [], vals = []; + let i = 1; + for (const f of fields) { + if (patch[f] !== undefined) { sets.push(`${f}=$${i++}`); vals.push(patch[f]); } + } + sets.push(`updated_at=now()`); + vals.push(id); + const { rows: [r] } = await pool.query( + `UPDATE spaces SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`, + vals + ); + await recordAudit(actor, 'update', 'space', id, before, r); + return r; +} + +export async function del(id, actor) { + const before = await getById(id); + await pool.query(`DELETE FROM spaces WHERE id=$1`, [id]); + await recordAudit(actor, 'delete', 'space', id, before, null); +} diff --git a/lib/db/repos/tasks.js b/lib/db/repos/tasks.js new file mode 100644 index 0000000..6bff501 --- /dev/null +++ b/lib/db/repos/tasks.js @@ -0,0 +1,58 @@ +import { pool } from '../pool.js'; +import { recordAudit } from './audit_stub.js'; + +export async function create({ space_id, project_id = null, title, body, priority, due_at, position }, actor) { + const { rows: [r] } = await pool.query( + `INSERT INTO tasks(space_id, project_id, title, body, priority, due_at, position) + VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING *`, + [space_id, project_id, title, body || null, priority || null, due_at || null, position || null] + ); + await recordAudit(actor, 'create', 'task', r.id, null, r); + return r; +} + +export async function getById(id) { + const { rows: [r] } = await pool.query(`SELECT * FROM tasks WHERE id=$1`, [id]); + return r; +} + +export async function listByProject(project_id) { + const { rows } = await pool.query( + `SELECT * FROM tasks WHERE project_id=$1 ORDER BY position NULLS LAST, created_at`, + [project_id] + ); + return rows; +} + +export async function listBySpace(space_id, { status } = {}) { + const sql = status + ? `SELECT * FROM tasks WHERE space_id=$1 AND status=$2 ORDER BY created_at` + : `SELECT * FROM tasks WHERE space_id=$1 ORDER BY created_at`; + const { rows } = await pool.query(sql, status ? [space_id, status] : [space_id]); + return rows; +} + +export async function update(id, patch, actor) { + const before = await getById(id); + const fields = ['title','body','status','priority','due_at','position','project_id']; + const sets = [], vals = []; + let i = 1; + for (const f of fields) { + if (patch[f] !== undefined) { sets.push(`${f}=$${i++}`); vals.push(patch[f]); } + } + if (patch.status === 'done') { sets.push(`completed_at=now()`); } + sets.push(`updated_at=now()`); + vals.push(id); + const { rows: [r] } = await pool.query( + `UPDATE tasks SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`, + vals + ); + await recordAudit(actor, 'update', 'task', id, before, r); + return r; +} + +export async function del(id, actor) { + const before = await getById(id); + await pool.query(`DELETE FROM tasks WHERE id=$1`, [id]); + await recordAudit(actor, 'delete', 'task', id, before, null); +} diff --git a/tests/repos/projects.test.js b/tests/repos/projects.test.js new file mode 100644 index 0000000..b0b7814 --- /dev/null +++ b/tests/repos/projects.test.js @@ -0,0 +1,39 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as spaces from '../../lib/db/repos/spaces.js'; +import * as projects from '../../lib/db/repos/projects.js'; + +const owner = { kind: 'user', id: null }; + +beforeEach(async () => { await resetDb(); await migrateUp(); }); + +describe('projects repo', () => { + it('creates a project under a space', async () => { + const s = await spaces.create({ slug: 'h', name: 'H' }, owner); + const p = await projects.create( + { space_id: s.id, slug: 'z2', name: 'Z2 Migration' }, owner + ); + expect(p.space_id).toBe(s.id); + expect(p.status).toBe('active'); + }); + + it('enforces unique slug per space', async () => { + const s = await spaces.create({ slug: 'h', name: 'H' }, owner); + await projects.create({ space_id: s.id, slug: 'a', name: 'A' }, owner); + await expect( + projects.create({ space_id: s.id, slug: 'a', name: 'B' }, owner) + ).rejects.toThrow(); + }); + + it('listBySpace filters by status', async () => { + const s = await spaces.create({ slug: 'h', name: 'H' }, owner); + await projects.create({ space_id: s.id, slug: 'a', name: 'A' }, owner); + await projects.create( + { space_id: s.id, slug: 'b', name: 'B', status: 'done' }, owner + ); + const active = await projects.listBySpace(s.id, { status: 'active' }); + expect(active).toHaveLength(1); + expect(active[0].slug).toBe('a'); + }); +}); diff --git a/tests/repos/spaces.test.js b/tests/repos/spaces.test.js new file mode 100644 index 0000000..395997f --- /dev/null +++ b/tests/repos/spaces.test.js @@ -0,0 +1,43 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as spaces from '../../lib/db/repos/spaces.js'; + +const owner = { kind: 'user', id: null }; + +beforeEach(async () => { await resetDb(); await migrateUp(); }); + +describe('spaces repo', () => { + it('creates and returns a space', async () => { + const s = await spaces.create({ slug: 'homelab', name: 'Homelab' }, owner); + expect(s.id).toBeDefined(); + expect(s.slug).toBe('homelab'); + }); + + it('getBySlug returns the row', async () => { + await spaces.create({ slug: 'a', name: 'A' }, owner); + const got = await spaces.getBySlug('a'); + expect(got.name).toBe('A'); + }); + + it('list returns all', async () => { + await spaces.create({ slug: 'a', name: 'A' }, owner); + await spaces.create({ slug: 'b', name: 'B' }, owner); + const all = await spaces.list(); + expect(all).toHaveLength(2); + }); + + it('update changes fields and bumps updated_at', async () => { + const s = await spaces.create({ slug: 'a', name: 'A' }, owner); + const u = await spaces.update(s.id, { name: 'Renamed' }, owner); + expect(u.name).toBe('Renamed'); + expect(new Date(u.updated_at).getTime()) + .toBeGreaterThanOrEqual(new Date(s.updated_at).getTime()); + }); + + it('del removes the row', async () => { + const s = await spaces.create({ slug: 'a', name: 'A' }, owner); + await spaces.del(s.id, owner); + expect(await spaces.getBySlug('a')).toBeUndefined(); + }); +}); diff --git a/tests/repos/tasks.test.js b/tests/repos/tasks.test.js new file mode 100644 index 0000000..63604a2 --- /dev/null +++ b/tests/repos/tasks.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as spaces from '../../lib/db/repos/spaces.js'; +import * as projects from '../../lib/db/repos/projects.js'; +import * as tasks from '../../lib/db/repos/tasks.js'; + +const owner = { kind: 'user', id: null }; + +beforeEach(async () => { await resetDb(); await migrateUp(); }); + +describe('tasks repo', () => { + it('creates a task with optional project', async () => { + const s = await spaces.create({ slug: 'h', name: 'H' }, owner); + const t = await tasks.create({ space_id: s.id, title: 'do it' }, owner); + expect(t.status).toBe('todo'); + expect(t.project_id).toBeNull(); + }); + + it('marking done sets completed_at', async () => { + const s = await spaces.create({ slug: 'h', name: 'H' }, owner); + const t = await tasks.create({ space_id: s.id, title: 'x' }, owner); + const u = await tasks.update(t.id, { status: 'done' }, owner); + expect(u.status).toBe('done'); + expect(u.completed_at).not.toBeNull(); + }); + + it('listByProject returns project tasks only', async () => { + const s = await spaces.create({ slug: 'h', name: 'H' }, owner); + const p = await projects.create({ space_id: s.id, slug: 'p', name: 'P' }, owner); + await tasks.create({ space_id: s.id, project_id: p.id, title: 'a' }, owner); + await tasks.create({ space_id: s.id, title: 'orphan' }, owner); + const list = await tasks.listByProject(p.id); + expect(list).toHaveLength(1); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index 5ccfc05..7c9f944 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -5,6 +5,7 @@ export default defineConfig({ globals: true, testTimeout: 10_000, coverage: { provider: 'v8', reporter: ['text', 'html'] }, - setupFiles: ['./tests/helpers/setup.js'] + setupFiles: ['./tests/helpers/setup.js'], + fileParallelism: false } });