Add meetings, sprints, and approval decision notes

Migration 042 creates meetings and sprints tables, adds decision_notes
to approvals. Full-stack: MeetingService, SprintService, IPC channels,
preload bridge, domain types. UI: MeetingsPage with scheduling and
type filtering, SprintsPage with progress tracking, ApprovalDetailDialog
with decision notes textarea.
This commit is contained in:
ZacharyZhang-NY committed 2026-06-07 18:01:58 -04:00
1 parent 75e5d17a86
commit ed9b3a3dd1
18 files changed
+430 -12

No files matched your search

@@ -25,6 +25,8 @@ import { TaskExecutionService } from "../services/task-execution-service";
import { DocumentService } from "../services/document-service";
import { KnowledgeService } from "../services/knowledge-service";
import { AutomationService } from "../services/automation-service";
import { MeetingService } from "../services/meeting-service";
import { SprintService } from "../services/sprint-service";
export interface IpcDeps {
connection: DatabaseConnection;
@@ -54,6 +56,8 @@ export function registerIpcHandlers(deps: IpcDeps): void {
const documents = new DocumentService(deps.connection.db);
const knowledge = new KnowledgeService(deps.connection.db);
const automation = new AutomationService(deps.connection.db);
const meetingService = new MeetingService(deps.connection.db);
const sprintService = new SprintService(deps.connection.db);
const onboarding = new OnboardingService({
db: deps.connection.db,
saveProviderCredential: (input) => providers.saveCredential(input),
@@ -120,6 +124,10 @@ export function registerIpcHandlers(deps: IpcDeps): void {
ipcMain.handle(IPC_CHANNELS.createAutomationRule, (_event, input) => mutate(() => automation.create(input)));
ipcMain.handle(IPC_CHANNELS.updateAutomationRule, (_event, input) => mutate(() => automation.update(input)));
ipcMain.handle(IPC_CHANNELS.deleteAutomationRule, (_event, input) => mutate(() => automation.delete(input)));
ipcMain.handle(IPC_CHANNELS.createMeeting, (_event, input) => mutate(() => meetingService.create(input)));
ipcMain.handle(IPC_CHANNELS.deleteMeeting, (_event, input) => mutate(() => meetingService.delete(input)));
ipcMain.handle(IPC_CHANNELS.createSprint, (_event, input) => mutate(() => sprintService.create(input)));
ipcMain.handle(IPC_CHANNELS.deleteSprint, (_event, input) => mutate(() => sprintService.delete(input)));
ipcMain.handle(IPC_CHANNELS.createApproval, (_event, input) => mutate(() => approvals.create(input)));
ipcMain.handle(IPC_CHANNELS.decideApproval, (_event, input) => mutate(() => approvals.decide(input)));
ipcMain.handle(IPC_CHANNELS.syncEmployeeIdentity, (_event, input) => mutate(() => identityAccess.syncEmployeeIdentity(input)));
@@ -0,0 +1,37 @@
export const approvalNotesMeetingsSprintsSchema = `
alter table approvals add column decision_notes text;
create table if not exists meetings (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
title text not null,
meeting_type text not null,
status text not null,
scheduled_at text,
duration_minutes integer not null,
agenda text not null,
notes text not null,
decisions text not null,
created_at text not null,
updated_at text not null
);
create index if not exists idx_meetings_org on meetings(org_id, scheduled_at);
create table if not exists sprints (
id text primary key,
org_id text not null references organizations(id) on delete cascade,
name text not null,
goal text not null,
status text not null,
start_date text not null,
end_date text not null,
velocity_points integer not null default 0,
completed_points integer not null default 0,
retrospective text not null,
created_at text not null,
updated_at text not null
);
create index if not exists idx_sprints_org on sprints(org_id, status);
`;
@@ -40,6 +40,7 @@ import { providerAuthProfileCascadeSchema } from "./migrations/038-provider-auth
import { auditImmutabilityOrgTeardownSchema } from "./migrations/039-audit-immutability-org-teardown";
import { documentsKnowledgeAutomationSchema } from "./migrations/040-documents-knowledge-automation";
import { taskSubtasksDeleteSchema } from "./migrations/041-task-subtasks-delete";
import { approvalNotesMeetingsSprintsSchema } from "./migrations/042-approval-notes-meetings-sprints";
interface Migration {
version: number;
@@ -89,6 +90,7 @@ const MIGRATIONS: Migration[] = [
{ version: 39, name: "audit_immutability_org_teardown", sql: auditImmutabilityOrgTeardownSchema },
{ version: 40, name: "documents_knowledge_automation", sql: documentsKnowledgeAutomationSchema },
{ version: 41, name: "task_subtasks_delete", sql: taskSubtasksDeleteSchema },
{ version: 42, name: "approval_notes_meetings_sprints", sql: approvalNotesMeetingsSprintsSchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -79,11 +79,12 @@ export class ApprovalService {
"Deciding employee was not found.",
);
const now = new Date().toISOString();
const notes = input.notes?.trim() || null;
const result = this.db.prepare(`
update approvals
set state = ?, decided_by_employee_id = ?, decided_at = ?
set state = ?, decided_by_employee_id = ?, decided_at = ?, decision_notes = ?
where id = ? and org_id = ? and state = ?
`).run(input.state, input.decidedByEmployeeId, now, input.approvalId, input.orgId, APPROVAL_STATE.PENDING);
`).run(input.state, input.decidedByEmployeeId, now, notes, input.approvalId, input.orgId, APPROVAL_STATE.PENDING);
if (result.changes === 0) throw new Error("Pending approval was not found.");
this.audit.record({
orgId: input.orgId,
@@ -0,0 +1,36 @@
import type { DatabaseSync } from "node:sqlite";
import type { CreateMeetingInput, DeleteMeetingInput } from "../../shared/commands";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
export class MeetingService {
private readonly audit: AuditService;
constructor(private readonly db: DatabaseSync) {
this.audit = new AuditService(db);
}
create(input: CreateMeetingInput): string {
const id = createId("mtg");
const now = new Date().toISOString();
this.db.prepare(`
insert into meetings(id, org_id, title, meeting_type, status, scheduled_at, duration_minutes, agenda, notes, decisions, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, input.orgId, input.title.trim(), input.meetingType, "scheduled", input.scheduledAt, input.durationMinutes, input.agenda.trim(), "", "", now, now);
this.audit.record({
orgId: input.orgId,
entityType: "meeting",
entityId: id,
actorType: "system",
actorId: null,
action: "created",
payload: { title: input.title.trim(), meetingType: input.meetingType },
});
return id;
}
delete(input: DeleteMeetingInput): void {
const result = this.db.prepare("delete from meetings where id = ? and org_id = ?").run(input.meetingId, input.orgId);
if (result.changes === 0) throw new Error("Meeting was not found.");
}
}
@@ -160,6 +160,8 @@ export class SnapshotService {
knowledgeEntries: list(this.db, "knowledge_entries", "updated_at desc"),
automationRules: list<AppSnapshot["automationRules"][number]>(this.db, "automation_rules", "priority asc")
.map((rule) => ({ ...rule, enabled: toBool(rule.enabled) })),
meetings: list(this.db, "meetings", "scheduled_at desc"),
sprints: list(this.db, "sprints", "start_date desc"),
};
}
@@ -0,0 +1,36 @@
import type { DatabaseSync } from "node:sqlite";
import type { CreateSprintInput, DeleteSprintInput } from "../../shared/commands";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
export class SprintService {
private readonly audit: AuditService;
constructor(private readonly db: DatabaseSync) {
this.audit = new AuditService(db);
}
create(input: CreateSprintInput): string {
const id = createId("spr");
const now = new Date().toISOString();
this.db.prepare(`
insert into sprints(id, org_id, name, goal, status, start_date, end_date, velocity_points, completed_points, retrospective, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)
`).run(id, input.orgId, input.name.trim(), input.goal.trim(), "planned", input.startDate, input.endDate, "", now, now);
this.audit.record({
orgId: input.orgId,
entityType: "sprint",
entityId: id,
actorType: "system",
actorId: null,
action: "created",
payload: { name: input.name.trim() },
});
return id;
}
delete(input: DeleteSprintInput): void {
const result = this.db.prepare("delete from sprints where id = ? and org_id = ?").run(input.sprintId, input.orgId);
if (result.changes === 0) throw new Error("Sprint was not found.");
}
}