b59904fdae
Data model - Properties, rooms (+optional floors), assets (typed custom fields + Zod runtime validator + move history), documents (polymorphic scope) - Projects -> work packages -> tasks -> subtasks - Decision events (scoped to project/property/asset/work_package) - Checklist templates + instances, maintenance schedules (time + usage) with auto-materialized checklists on event recording - Wiki (global + per-project) with revisions + tsvector FTS - Property accounts (utility/meter numbers by kind) - Notifications table + per-user channel prefs Infra - RBAC guards (requireCompany / requireAdmin) - Storage abstraction: LocalDiskStorage (HMAC signed URLs) + S3Storage behind the same interface, switchable via STORAGE_BACKEND - CSV export for assets / maintenance / decisions - QR labels: /api/qr SVG endpoint + printable /assets/[id]/label - Notifications: in-app + SMTP (own server via nodemailer) + Matrix (Client-Server API, per-company room) with opt-in per user - Company switcher + auto-select first company on login UI - Topbar: bell with unread count, theme toggle, name, Sign Out (flat) - Sidebar: main nav + dedicated Admin section (Asset types, Users, Company) - Nested-route tabs on property / project / asset detail pages - Admin UIs for users (invite, role, reset pw, deactivate) and company settings (default currency, Matrix room id) - Custom asset type creation + field-def editor with immutable key/type guard and auto-deprecate when removing a field still referenced Graph - graphify-out/ committed: GRAPH_REPORT.md, graph.html, graph.json
168 lines
5.2 KiB
TypeScript
168 lines
5.2 KiB
TypeScript
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
|
|
import { db } from '$lib/server/db/client';
|
|
import { projects, subtasks, tasks, workPackages } from '$lib/server/db/schema/projects';
|
|
import { notify } from './notifications';
|
|
|
|
export type TaskStatus = 'todo' | 'doing' | 'done' | 'blocked';
|
|
|
|
async function assertWorkPackage(companyId: string, wpId: string): Promise<void> {
|
|
const [row] = await db
|
|
.select({ id: workPackages.id })
|
|
.from(workPackages)
|
|
.innerJoin(projects, eq(projects.id, workPackages.projectId))
|
|
.where(
|
|
and(
|
|
eq(workPackages.id, wpId),
|
|
eq(projects.companyId, companyId),
|
|
isNull(workPackages.deletedAt)
|
|
)
|
|
)
|
|
.limit(1);
|
|
if (!row) throw new Error('work package not found');
|
|
}
|
|
|
|
export async function listTasksForWorkPackage(companyId: string, wpId: string) {
|
|
await assertWorkPackage(companyId, wpId);
|
|
return db
|
|
.select()
|
|
.from(tasks)
|
|
.where(and(eq(tasks.workPackageId, wpId), isNull(tasks.deletedAt)))
|
|
.orderBy(asc(tasks.order), asc(tasks.createdAt));
|
|
}
|
|
|
|
export async function createTask(input: {
|
|
companyId: string;
|
|
createdBy: string;
|
|
workPackageId: string;
|
|
title: string;
|
|
description?: string | null;
|
|
assigneeId?: string | null;
|
|
dueAt?: Date | null;
|
|
}): Promise<{ id: string }> {
|
|
await assertWorkPackage(input.companyId, input.workPackageId);
|
|
const [{ next }] = await db
|
|
.select({ next: sql<number>`coalesce(max(${tasks.order}), -1) + 1` })
|
|
.from(tasks)
|
|
.where(eq(tasks.workPackageId, input.workPackageId));
|
|
const [row] = await db
|
|
.insert(tasks)
|
|
.values({
|
|
workPackageId: input.workPackageId,
|
|
title: input.title.trim(),
|
|
description: input.description ?? null,
|
|
assigneeId: input.assigneeId ?? null,
|
|
dueAt: input.dueAt ?? null,
|
|
order: next ?? 0,
|
|
createdBy: input.createdBy
|
|
})
|
|
.returning({ id: tasks.id });
|
|
return row;
|
|
}
|
|
|
|
export async function getTaskWithSubtasks(companyId: string, id: string) {
|
|
const [t] = await db
|
|
.select({ task: tasks, projectId: workPackages.projectId, wpName: workPackages.name })
|
|
.from(tasks)
|
|
.innerJoin(workPackages, eq(workPackages.id, tasks.workPackageId))
|
|
.innerJoin(projects, eq(projects.id, workPackages.projectId))
|
|
.where(and(eq(tasks.id, id), eq(projects.companyId, companyId), isNull(tasks.deletedAt)))
|
|
.limit(1);
|
|
if (!t) return null;
|
|
const subs = await db
|
|
.select()
|
|
.from(subtasks)
|
|
.where(eq(subtasks.taskId, id))
|
|
.orderBy(asc(subtasks.order), asc(subtasks.createdAt));
|
|
return { task: t.task, projectId: t.projectId, workPackageName: t.wpName, subtasks: subs };
|
|
}
|
|
|
|
export async function updateTask(
|
|
companyId: string,
|
|
id: string,
|
|
patch: {
|
|
title?: string;
|
|
description?: string | null;
|
|
status?: TaskStatus;
|
|
assigneeId?: string | null;
|
|
dueAt?: Date | null;
|
|
}
|
|
): Promise<void> {
|
|
const t = await getTaskWithSubtasks(companyId, id);
|
|
if (!t) throw new Error('task not found');
|
|
|
|
const update: Partial<typeof tasks.$inferInsert> = {};
|
|
if (patch.title !== undefined) update.title = patch.title.trim();
|
|
if (patch.description !== undefined) update.description = patch.description;
|
|
if (patch.assigneeId !== undefined) update.assigneeId = patch.assigneeId;
|
|
if (patch.dueAt !== undefined) update.dueAt = patch.dueAt;
|
|
if (patch.status !== undefined) {
|
|
update.status = patch.status;
|
|
if (patch.status === 'done' && !t.task.completedAt) update.completedAt = new Date();
|
|
if (patch.status !== 'done' && t.task.completedAt) update.completedAt = null;
|
|
}
|
|
|
|
await db.update(tasks).set(update).where(eq(tasks.id, id));
|
|
|
|
// Fire task_assigned when assignee actually changes to a real user.
|
|
const newAssignee = patch.assigneeId ?? null;
|
|
if (
|
|
patch.assigneeId !== undefined &&
|
|
newAssignee &&
|
|
newAssignee !== t.task.assigneeId
|
|
) {
|
|
const title = patch.title ?? t.task.title;
|
|
void notify({
|
|
companyId,
|
|
userIds: [newAssignee],
|
|
kind: 'task_assigned',
|
|
title: `Task assigned: ${title}`,
|
|
body: t.task.description?.slice(0, 500) ?? 'You were assigned this task.',
|
|
link: `/projects/${t.projectId}/work/${t.task.workPackageId}/${id}`
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function softDeleteTask(companyId: string, id: string): Promise<void> {
|
|
const t = await getTaskWithSubtasks(companyId, id);
|
|
if (!t) throw new Error('task not found');
|
|
await db
|
|
.update(tasks)
|
|
.set({ deletedAt: sql`now()` })
|
|
.where(eq(tasks.id, id));
|
|
}
|
|
|
|
// --- subtasks ---------------------------------------------------------------
|
|
|
|
export async function addSubtask(companyId: string, taskId: string, name: string): Promise<void> {
|
|
const t = await getTaskWithSubtasks(companyId, taskId);
|
|
if (!t) throw new Error('task not found');
|
|
const order = t.subtasks.length;
|
|
await db.insert(subtasks).values({ taskId, name: name.trim(), order });
|
|
}
|
|
|
|
export async function toggleSubtask(
|
|
companyId: string,
|
|
taskId: string,
|
|
subtaskId: string,
|
|
done: boolean
|
|
): Promise<void> {
|
|
const t = await getTaskWithSubtasks(companyId, taskId);
|
|
if (!t) throw new Error('task not found');
|
|
await db
|
|
.update(subtasks)
|
|
.set({ done })
|
|
.where(and(eq(subtasks.id, subtaskId), eq(subtasks.taskId, taskId)));
|
|
}
|
|
|
|
export async function removeSubtask(
|
|
companyId: string,
|
|
taskId: string,
|
|
subtaskId: string
|
|
): Promise<void> {
|
|
const t = await getTaskWithSubtasks(companyId, taskId);
|
|
if (!t) throw new Error('task not found');
|
|
await db
|
|
.delete(subtasks)
|
|
.where(and(eq(subtasks.id, subtaskId), eq(subtasks.taskId, taskId)));
|
|
}
|