Files
buildfor_life_ops/src/lib/server/services/tasks.ts
T
grabowski b59904fdae Phases 1-5 + rooms/floors, accounts, custom types, users, notifications
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
2026-04-23 15:18:11 +07:00

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