From 6276aed7953147677311efb161f1905ac9d3c048 Mon Sep 17 00:00:00 2001 From: Flux_bot Date: Wed, 11 Feb 2026 11:10:36 +0000 Subject: [PATCH] =?UTF-8?q?feat(activities):=20Aktivit=C3=A4ten=20&=20Time?= =?UTF-8?q?line=20implementiert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - CRUD für Notizen, Anrufe, Emails, Meetings, Tasks - Timeline View für Kontakte/Firmen/Deals - Upcoming Tasks (nächste X Tage) - Overdue Tasks - Mark Complete/Reopen - Activity Statistics (by type, today, overdue) - Erinnerungen (reminder_at) - Duration Tracking - Outcome Recording Task: #11 Aktivitäten & Timeline --- src/main.ts | 9 +- src/repositories/activity.ts | 457 +++++++++++++++++++++++++++++++++++ src/routes/activities.ts | 418 ++++++++++++++++++++++++-------- 3 files changed, 779 insertions(+), 105 deletions(-) create mode 100644 src/repositories/activity.ts diff --git a/src/main.ts b/src/main.ts index ffc93d1..d8bcf34 100644 --- a/src/main.ts +++ b/src/main.ts @@ -184,9 +184,16 @@ app.use(async (ctx, next) => { }, activities: { "GET /api/v1/activities": "List activities", + "GET /api/v1/activities/stats": "Activity statistics", "GET /api/v1/activities/upcoming": "Upcoming tasks", + "GET /api/v1/activities/overdue": "Overdue tasks", + "GET /api/v1/activities/timeline/:type/:id": "Entity timeline", + "GET /api/v1/activities/:id": "Get activity", "POST /api/v1/activities": "Create activity", - "POST /api/v1/activities/:id/complete": "Complete task", + "PUT /api/v1/activities/:id": "Update activity", + "POST /api/v1/activities/:id/complete": "Mark completed", + "POST /api/v1/activities/:id/reopen": "Reopen activity", + "DELETE /api/v1/activities/:id": "Delete activity", }, pipelines: { "GET /api/v1/pipelines": "List pipelines", diff --git a/src/repositories/activity.ts b/src/repositories/activity.ts new file mode 100644 index 0000000..25996f1 --- /dev/null +++ b/src/repositories/activity.ts @@ -0,0 +1,457 @@ +import { query, queryOne, execute } from "../db/connection.ts"; + +// ============================================ +// ACTIVITY REPOSITORY +// ============================================ + +export type ActivityType = "note" | "call" | "email" | "meeting" | "task"; + +export interface Activity { + id: string; + org_id: string; + contact_id?: string; + company_id?: string; + deal_id?: string; + type: ActivityType; + subject: string; + description?: string; + due_date?: Date; + completed_at?: Date; + is_completed: boolean; + reminder_at?: Date; + duration_minutes?: number; + outcome?: string; + assigned_to?: string; + created_by?: string; + created_at: Date; + updated_at: Date; + deleted_at?: Date; +} + +export interface ActivityWithRelations extends Activity { + contact_name?: string; + company_name?: string; + deal_title?: string; + assigned_name?: string; + creator_name?: string; +} + +export interface ActivityFilters { + type?: ActivityType; + contactId?: string; + companyId?: string; + dealId?: string; + assignedTo?: string; + isCompleted?: boolean; + dueBefore?: Date; + dueAfter?: Date; +} + +/** + * List activities with filters + */ +export async function findAll( + orgId: string, + filters: ActivityFilters = {}, + pagination: { page: number; limit: number } = { page: 1, limit: 50 } +): Promise<{ activities: ActivityWithRelations[]; total: number }> { + const { page, limit } = pagination; + const offset = (page - 1) * limit; + + const conditions: string[] = ["a.org_id = $1", "a.deleted_at IS NULL"]; + const params: unknown[] = [orgId]; + let paramIndex = 2; + + if (filters.type) { + conditions.push(`a.type = $${paramIndex}`); + params.push(filters.type); + paramIndex++; + } + + if (filters.contactId) { + conditions.push(`a.contact_id = $${paramIndex}`); + params.push(filters.contactId); + paramIndex++; + } + + if (filters.companyId) { + conditions.push(`a.company_id = $${paramIndex}`); + params.push(filters.companyId); + paramIndex++; + } + + if (filters.dealId) { + conditions.push(`a.deal_id = $${paramIndex}`); + params.push(filters.dealId); + paramIndex++; + } + + if (filters.assignedTo) { + conditions.push(`a.assigned_to = $${paramIndex}`); + params.push(filters.assignedTo); + paramIndex++; + } + + if (filters.isCompleted !== undefined) { + conditions.push(`a.is_completed = $${paramIndex}`); + params.push(filters.isCompleted); + paramIndex++; + } + + if (filters.dueBefore) { + conditions.push(`a.due_date <= $${paramIndex}`); + params.push(filters.dueBefore); + paramIndex++; + } + + if (filters.dueAfter) { + conditions.push(`a.due_date >= $${paramIndex}`); + params.push(filters.dueAfter); + paramIndex++; + } + + const whereClause = conditions.join(" AND "); + + // Get total + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM activities a WHERE ${whereClause}`, + params + ); + const total = parseInt(countResult?.count || "0"); + + // Get activities + const activities = await query( + `SELECT + a.*, + c.first_name || ' ' || c.last_name as contact_name, + co.name as company_name, + d.title as deal_title, + u1.first_name || ' ' || u1.last_name as assigned_name, + u2.first_name || ' ' || u2.last_name as creator_name + FROM activities a + LEFT JOIN contacts c ON c.id = a.contact_id + LEFT JOIN companies co ON co.id = a.company_id + LEFT JOIN deals d ON d.id = a.deal_id + LEFT JOIN users u1 ON u1.id = a.assigned_to + LEFT JOIN users u2 ON u2.id = a.created_by + WHERE ${whereClause} + ORDER BY + CASE WHEN a.is_completed = false AND a.due_date IS NOT NULL THEN 0 ELSE 1 END, + a.due_date ASC NULLS LAST, + a.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + [...params, limit, offset] + ); + + return { activities, total }; +} + +/** + * Get timeline for an entity (contact, company, or deal) + */ +export async function getTimeline( + orgId: string, + entityType: "contact" | "company" | "deal", + entityId: string, + limit: number = 50 +): Promise { + const column = entityType === "contact" ? "contact_id" + : entityType === "company" ? "company_id" + : "deal_id"; + + return await query( + `SELECT + a.*, + c.first_name || ' ' || c.last_name as contact_name, + co.name as company_name, + d.title as deal_title, + u1.first_name || ' ' || u1.last_name as assigned_name, + u2.first_name || ' ' || u2.last_name as creator_name + FROM activities a + LEFT JOIN contacts c ON c.id = a.contact_id + LEFT JOIN companies co ON co.id = a.company_id + LEFT JOIN deals d ON d.id = a.deal_id + LEFT JOIN users u1 ON u1.id = a.assigned_to + LEFT JOIN users u2 ON u2.id = a.created_by + WHERE a.org_id = $1 AND a.${column} = $2 AND a.deleted_at IS NULL + ORDER BY a.created_at DESC + LIMIT $3`, + [orgId, entityId, limit] + ); +} + +/** + * Get upcoming tasks/activities + */ +export async function getUpcoming( + orgId: string, + userId?: string, + days: number = 7 +): Promise { + const userCondition = userId ? `AND a.assigned_to = $3` : ""; + const params = userId ? [orgId, days, userId] : [orgId, days]; + + return await query( + `SELECT + a.*, + c.first_name || ' ' || c.last_name as contact_name, + co.name as company_name, + d.title as deal_title, + u1.first_name || ' ' || u1.last_name as assigned_name + FROM activities a + LEFT JOIN contacts c ON c.id = a.contact_id + LEFT JOIN companies co ON co.id = a.company_id + LEFT JOIN deals d ON d.id = a.deal_id + LEFT JOIN users u1 ON u1.id = a.assigned_to + WHERE a.org_id = $1 + AND a.is_completed = false + AND a.due_date IS NOT NULL + AND a.due_date <= CURRENT_DATE + INTERVAL '${days} days' + AND a.deleted_at IS NULL + ${userCondition} + ORDER BY a.due_date ASC`, + params + ); +} + +/** + * Get overdue tasks + */ +export async function getOverdue(orgId: string, userId?: string): Promise { + const userCondition = userId ? `AND a.assigned_to = $2` : ""; + const params = userId ? [orgId, userId] : [orgId]; + + return await query( + `SELECT + a.*, + c.first_name || ' ' || c.last_name as contact_name, + co.name as company_name, + d.title as deal_title + FROM activities a + LEFT JOIN contacts c ON c.id = a.contact_id + LEFT JOIN companies co ON co.id = a.company_id + LEFT JOIN deals d ON d.id = a.deal_id + WHERE a.org_id = $1 + AND a.is_completed = false + AND a.due_date IS NOT NULL + AND a.due_date < CURRENT_DATE + AND a.deleted_at IS NULL + ${userCondition} + ORDER BY a.due_date ASC`, + params + ); +} + +/** + * Find activity by ID + */ +export async function findById(orgId: string, activityId: string): Promise { + const rows = await query( + `SELECT + a.*, + c.first_name || ' ' || c.last_name as contact_name, + co.name as company_name, + d.title as deal_title, + u1.first_name || ' ' || u1.last_name as assigned_name, + u2.first_name || ' ' || u2.last_name as creator_name + FROM activities a + LEFT JOIN contacts c ON c.id = a.contact_id + LEFT JOIN companies co ON co.id = a.company_id + LEFT JOIN deals d ON d.id = a.deal_id + LEFT JOIN users u1 ON u1.id = a.assigned_to + LEFT JOIN users u2 ON u2.id = a.created_by + WHERE a.id = $1 AND a.org_id = $2 AND a.deleted_at IS NULL`, + [activityId, orgId] + ); + return rows[0] || null; +} + +/** + * Create activity + */ +export async function create(data: { + orgId: string; + type: ActivityType; + subject: string; + description?: string; + contactId?: string; + companyId?: string; + dealId?: string; + dueDate?: Date; + reminderAt?: Date; + durationMinutes?: number; + outcome?: string; + assignedTo?: string; + createdBy?: string; +}): Promise { + const rows = await query( + `INSERT INTO activities ( + org_id, type, subject, description, contact_id, company_id, deal_id, + due_date, reminder_at, duration_minutes, outcome, assigned_to, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING *`, + [ + data.orgId, + data.type, + data.subject, + data.description || null, + data.contactId || null, + data.companyId || null, + data.dealId || null, + data.dueDate || null, + data.reminderAt || null, + data.durationMinutes || null, + data.outcome || null, + data.assignedTo || null, + data.createdBy || null, + ] + ); + return rows[0]; +} + +/** + * Update activity + */ +export async function update( + orgId: string, + activityId: string, + data: Partial<{ + subject: string; + description: string; + dueDate: Date | null; + reminderAt: Date | null; + durationMinutes: number; + outcome: string; + assignedTo: string | null; + }> +): Promise { + const updates: string[] = []; + const params: unknown[] = []; + let paramIndex = 1; + + const fieldMap: Record = { + subject: "subject", + description: "description", + dueDate: "due_date", + reminderAt: "reminder_at", + durationMinutes: "duration_minutes", + outcome: "outcome", + assignedTo: "assigned_to", + }; + + for (const [key, dbField] of Object.entries(fieldMap)) { + if (key in data) { + updates.push(`${dbField} = $${paramIndex}`); + params.push(data[key as keyof typeof data]); + paramIndex++; + } + } + + if (updates.length === 0) { + return await findById(orgId, activityId) as Activity | null; + } + + params.push(activityId, orgId); + + const rows = await query( + `UPDATE activities SET ${updates.join(", ")} + WHERE id = $${paramIndex} AND org_id = $${paramIndex + 1} AND deleted_at IS NULL + RETURNING *`, + params + ); + + return rows[0] || null; +} + +/** + * Mark activity as completed + */ +export async function complete(orgId: string, activityId: string, outcome?: string): Promise { + const rows = await query( + `UPDATE activities + SET is_completed = true, completed_at = NOW(), outcome = COALESCE($1, outcome) + WHERE id = $2 AND org_id = $3 AND deleted_at IS NULL + RETURNING *`, + [outcome || null, activityId, orgId] + ); + return rows[0] || null; +} + +/** + * Reopen activity + */ +export async function reopen(orgId: string, activityId: string): Promise { + const rows = await query( + `UPDATE activities + SET is_completed = false, completed_at = NULL + WHERE id = $1 AND org_id = $2 AND deleted_at IS NULL + RETURNING *`, + [activityId, orgId] + ); + return rows[0] || null; +} + +/** + * Delete activity + */ +export async function softDelete(orgId: string, activityId: string): Promise { + const count = await execute( + `UPDATE activities SET deleted_at = NOW() WHERE id = $1 AND org_id = $2 AND deleted_at IS NULL`, + [activityId, orgId] + ); + return count > 0; +} + +/** + * Get activity statistics + */ +export async function getStats(orgId: string, userId?: string): Promise<{ + totalToday: number; + completedToday: number; + overdue: number; + upcoming: number; + byType: Record; +}> { + const userCondition = userId ? `AND assigned_to = $2` : ""; + const params = userId ? [orgId, userId] : [orgId]; + + const result = await queryOne<{ + total_today: string; + completed_today: string; + overdue: string; + upcoming: string; + notes: string; + calls: string; + emails: string; + meetings: string; + tasks: string; + }>( + `SELECT + COUNT(*) FILTER (WHERE due_date::date = CURRENT_DATE) as total_today, + COUNT(*) FILTER (WHERE completed_at::date = CURRENT_DATE) as completed_today, + COUNT(*) FILTER (WHERE is_completed = false AND due_date < CURRENT_DATE) as overdue, + COUNT(*) FILTER (WHERE is_completed = false AND due_date > CURRENT_DATE AND due_date <= CURRENT_DATE + INTERVAL '7 days') as upcoming, + COUNT(*) FILTER (WHERE type = 'note') as notes, + COUNT(*) FILTER (WHERE type = 'call') as calls, + COUNT(*) FILTER (WHERE type = 'email') as emails, + COUNT(*) FILTER (WHERE type = 'meeting') as meetings, + COUNT(*) FILTER (WHERE type = 'task') as tasks + FROM activities + WHERE org_id = $1 AND deleted_at IS NULL ${userCondition}`, + params + ); + + return { + totalToday: parseInt(result?.total_today || "0"), + completedToday: parseInt(result?.completed_today || "0"), + overdue: parseInt(result?.overdue || "0"), + upcoming: parseInt(result?.upcoming || "0"), + byType: { + note: parseInt(result?.notes || "0"), + call: parseInt(result?.calls || "0"), + email: parseInt(result?.emails || "0"), + meeting: parseInt(result?.meetings || "0"), + task: parseInt(result?.tasks || "0"), + }, + }; +} diff --git a/src/routes/activities.ts b/src/routes/activities.ts index 1412439..6fb34db 100644 --- a/src/routes/activities.ts +++ b/src/routes/activities.ts @@ -1,148 +1,317 @@ import { Router } from "@oak/oak"; +import { z } from "zod"; +import * as activityRepo from "../repositories/activity.ts"; +import { requireAuth } from "../middleware/auth.ts"; +import type { AuthState } from "../types/index.ts"; -const router = new Router({ prefix: "/api/v1/activities" }); +const router = new Router({ prefix: "/api/v1/activities" }); + +// ============================================ +// VALIDATION SCHEMAS +// ============================================ + +const activityTypes = ["note", "call", "email", "meeting", "task"] as const; + +const createActivitySchema = z.object({ + type: z.enum(activityTypes), + subject: z.string().min(1).max(200), + description: z.string().optional().nullable(), + contactId: z.string().uuid().optional().nullable(), + companyId: z.string().uuid().optional().nullable(), + dealId: z.string().uuid().optional().nullable(), + dueDate: z.string().datetime().optional().nullable(), + reminderAt: z.string().datetime().optional().nullable(), + durationMinutes: z.number().int().min(1).optional().nullable(), + outcome: z.string().max(50).optional().nullable(), + assignedTo: z.string().uuid().optional().nullable(), +}); + +const updateActivitySchema = z.object({ + subject: z.string().min(1).max(200).optional(), + description: z.string().optional().nullable(), + dueDate: z.string().datetime().optional().nullable(), + reminderAt: z.string().datetime().optional().nullable(), + durationMinutes: z.number().int().min(1).optional().nullable(), + outcome: z.string().max(50).optional().nullable(), + assignedTo: z.string().uuid().optional().nullable(), +}); + +const completeSchema = z.object({ + outcome: z.string().max(50).optional(), +}); + +const listQuerySchema = z.object({ + page: z.coerce.number().min(1).default(1), + limit: z.coerce.number().min(1).max(100).default(50), + type: z.enum(activityTypes).optional(), + contactId: z.string().uuid().optional(), + companyId: z.string().uuid().optional(), + dealId: z.string().uuid().optional(), + assignedTo: z.string().uuid().optional(), + isCompleted: z.enum(["true", "false"]).optional(), +}); + +// ============================================ +// ROUTES +// ============================================ // GET /api/v1/activities - List activities -router.get("/", async (ctx) => { - const query = ctx.request.url.searchParams; - const page = parseInt(query.get("page") || "1"); - const limit = parseInt(query.get("limit") || "20"); - const type = query.get("type"); // note, call, email, meeting, task - const contactId = query.get("contactId"); - const dealId = query.get("dealId"); - const userId = query.get("userId"); - const completed = query.get("completed"); +router.get("/", requireAuth, async (ctx) => { + const queryParams = Object.fromEntries(ctx.request.url.searchParams); + const result = listQuerySchema.safeParse(queryParams); + + if (!result.success) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid query parameters" }, + }; + return; + } + + const { page, limit, isCompleted, ...filters } = result.data; + + const { activities, total } = await activityRepo.findAll( + ctx.state.orgId, + { + ...filters, + isCompleted: isCompleted === "true" ? true : isCompleted === "false" ? false : undefined, + }, + { page, limit } + ); ctx.response.body = { success: true, - data: [ - { - id: "act-1", - type: "call", - subject: "Erstgespräch mit TechStart", - description: "Sehr interessiert an CRM Lösung", - contact: { id: "c-1", name: "Sarah Müller" }, - deal: { id: "d-1", title: "TechStart CRM" }, - user: { id: "u-1", name: "Max Mustermann" }, - isCompleted: true, - completedAt: "2026-02-01T15:00:00Z", - createdAt: "2026-02-01T14:00:00Z", - }, - { - id: "act-2", - type: "task", - subject: "Angebot erstellen", - dueDate: "2026-02-12T17:00:00Z", - contact: { id: "c-2", name: "Michael Fischer" }, - deal: { id: "d-3", title: "ScaleUp Deal" }, - user: { id: "u-1", name: "Max Mustermann" }, - isCompleted: false, - createdAt: "2026-02-10T09:00:00Z", - }, - ], - meta: { page, limit, total: 25 }, - }; -}); - -// GET /api/v1/activities/upcoming - Upcoming tasks & meetings -router.get("/upcoming", async (ctx) => { - ctx.response.body = { - success: true, - data: { - today: [ - { - id: "act-2", - type: "task", - subject: "Angebot erstellen", - dueDate: "2026-02-11T17:00:00Z", - }, - ], - thisWeek: [ - { - id: "act-3", - type: "meeting", - subject: "Demo Präsentation", - dueDate: "2026-02-15T14:00:00Z", - }, - ], - overdue: [], + data: activities.map(formatActivity), + meta: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), }, }; }); -// GET /api/v1/activities/:id -router.get("/:id", async (ctx) => { - const { id } = ctx.params; +// GET /api/v1/activities/stats - Get activity statistics +router.get("/stats", requireAuth, async (ctx) => { + const userId = ctx.request.url.searchParams.get("userId") || undefined; + const stats = await activityRepo.getStats(ctx.state.orgId, userId); ctx.response.body = { success: true, - data: { - id, - type: "call", - subject: "Erstgespräch", - description: "Details...", - durationMinutes: 30, - callOutcome: "successful", - contact: { id: "c-1", firstName: "Sarah", lastName: "Müller" }, - deal: { id: "d-1", title: "TechStart CRM" }, - user: { id: "u-1", firstName: "Max", lastName: "Mustermann" }, - isCompleted: true, - completedAt: "2026-02-01T15:00:00Z", - createdAt: "2026-02-01T14:00:00Z", - }, + data: stats, + }; +}); + +// GET /api/v1/activities/upcoming - Get upcoming tasks +router.get("/upcoming", requireAuth, async (ctx) => { + const days = parseInt(ctx.request.url.searchParams.get("days") || "7"); + const myOnly = ctx.request.url.searchParams.get("myOnly") === "true"; + + const activities = await activityRepo.getUpcoming( + ctx.state.orgId, + myOnly ? ctx.state.user.id : undefined, + Math.min(days, 30) + ); + + ctx.response.body = { + success: true, + data: activities.map(formatActivity), + }; +}); + +// GET /api/v1/activities/overdue - Get overdue tasks +router.get("/overdue", requireAuth, async (ctx) => { + const myOnly = ctx.request.url.searchParams.get("myOnly") === "true"; + + const activities = await activityRepo.getOverdue( + ctx.state.orgId, + myOnly ? ctx.state.user.id : undefined + ); + + ctx.response.body = { + success: true, + data: activities.map(formatActivity), + }; +}); + +// GET /api/v1/activities/timeline/:entityType/:entityId - Get timeline +router.get("/timeline/:entityType/:entityId", requireAuth, async (ctx) => { + const { entityType, entityId } = ctx.params; + + if (!["contact", "company", "deal"].includes(entityType)) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { code: "INVALID_ENTITY", message: "Entity type must be contact, company, or deal" }, + }; + return; + } + + const limit = parseInt(ctx.request.url.searchParams.get("limit") || "50"); + + const activities = await activityRepo.getTimeline( + ctx.state.orgId, + entityType as "contact" | "company" | "deal", + entityId, + Math.min(limit, 200) + ); + + ctx.response.body = { + success: true, + data: activities.map(formatActivity), + }; +}); + +// GET /api/v1/activities/:id - Get single activity +router.get("/:id", requireAuth, async (ctx) => { + const activity = await activityRepo.findById(ctx.state.orgId, ctx.params.id); + + if (!activity) { + ctx.response.status = 404; + ctx.response.body = { + success: false, + error: { code: "NOT_FOUND", message: "Activity not found" }, + }; + return; + } + + ctx.response.body = { + success: true, + data: formatActivity(activity), }; }); // POST /api/v1/activities - Create activity -router.post("/", async (ctx) => { +router.post("/", requireAuth, async (ctx) => { const body = await ctx.request.body.json(); + const result = createActivitySchema.safeParse(body); + + if (!result.success) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid input", details: result.error.errors }, + }; + return; + } + + const data = result.data; + + const activity = await activityRepo.create({ + orgId: ctx.state.orgId, + type: data.type, + subject: data.subject, + description: data.description || undefined, + contactId: data.contactId || undefined, + companyId: data.companyId || undefined, + dealId: data.dealId || undefined, + dueDate: data.dueDate ? new Date(data.dueDate) : undefined, + reminderAt: data.reminderAt ? new Date(data.reminderAt) : undefined, + durationMinutes: data.durationMinutes || undefined, + outcome: data.outcome || undefined, + assignedTo: data.assignedTo || ctx.state.user.id, + createdBy: ctx.state.user.id, + }); ctx.response.status = 201; ctx.response.body = { success: true, - message: "Activity created", - data: { - id: "new-act-uuid", - ...body, - createdAt: new Date().toISOString(), - }, + data: formatActivity(activity as activityRepo.ActivityWithRelations), }; }); // PUT /api/v1/activities/:id - Update activity -router.put("/:id", async (ctx) => { - const { id } = ctx.params; +router.put("/:id", requireAuth, async (ctx) => { const body = await ctx.request.body.json(); + const result = updateActivitySchema.safeParse(body); + + if (!result.success) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid input", details: result.error.errors }, + }; + return; + } + + const data = result.data; + const activity = await activityRepo.update(ctx.state.orgId, ctx.params.id, { + ...data, + dueDate: data.dueDate ? new Date(data.dueDate) : data.dueDate, + reminderAt: data.reminderAt ? new Date(data.reminderAt) : data.reminderAt, + }); + + if (!activity) { + ctx.response.status = 404; + ctx.response.body = { + success: false, + error: { code: "NOT_FOUND", message: "Activity not found" }, + }; + return; + } ctx.response.body = { success: true, - message: "Activity updated", - data: { - id, - ...body, - updatedAt: new Date().toISOString(), - }, + data: formatActivity(activity as activityRepo.ActivityWithRelations), }; }); // POST /api/v1/activities/:id/complete - Mark as completed -router.post("/:id/complete", async (ctx) => { - const { id } = ctx.params; +router.post("/:id/complete", requireAuth, async (ctx) => { + const body = await ctx.request.body.json().catch(() => ({})); + const result = completeSchema.safeParse(body); + const outcome = result.success ? result.data.outcome : undefined; + + const activity = await activityRepo.complete(ctx.state.orgId, ctx.params.id, outcome); + + if (!activity) { + ctx.response.status = 404; + ctx.response.body = { + success: false, + error: { code: "NOT_FOUND", message: "Activity not found" }, + }; + return; + } ctx.response.body = { success: true, - message: "Activity completed", - data: { - id, - isCompleted: true, - completedAt: new Date().toISOString(), - }, + data: formatActivity(activity as activityRepo.ActivityWithRelations), + message: "Activity completed ✓", }; }); -// DELETE /api/v1/activities/:id -router.delete("/:id", async (ctx) => { - const { id } = ctx.params; +// POST /api/v1/activities/:id/reopen - Reopen activity +router.post("/:id/reopen", requireAuth, async (ctx) => { + const activity = await activityRepo.reopen(ctx.state.orgId, ctx.params.id); + + if (!activity) { + ctx.response.status = 404; + ctx.response.body = { + success: false, + error: { code: "NOT_FOUND", message: "Activity not found" }, + }; + return; + } + + ctx.response.body = { + success: true, + data: formatActivity(activity as activityRepo.ActivityWithRelations), + }; +}); + +// DELETE /api/v1/activities/:id - Delete activity +router.delete("/:id", requireAuth, async (ctx) => { + const deleted = await activityRepo.softDelete(ctx.state.orgId, ctx.params.id); + + if (!deleted) { + ctx.response.status = 404; + ctx.response.body = { + success: false, + error: { code: "NOT_FOUND", message: "Activity not found" }, + }; + return; + } ctx.response.body = { success: true, @@ -150,4 +319,45 @@ router.delete("/:id", async (ctx) => { }; }); +// ============================================ +// HELPER FUNCTIONS +// ============================================ + +function formatActivity(activity: activityRepo.ActivityWithRelations) { + return { + id: activity.id, + type: activity.type, + subject: activity.subject, + description: activity.description, + isCompleted: activity.is_completed, + completedAt: activity.completed_at, + dueDate: activity.due_date, + reminderAt: activity.reminder_at, + durationMinutes: activity.duration_minutes, + outcome: activity.outcome, + contact: activity.contact_id ? { + id: activity.contact_id, + name: activity.contact_name, + } : null, + company: activity.company_id ? { + id: activity.company_id, + name: activity.company_name, + } : null, + deal: activity.deal_id ? { + id: activity.deal_id, + title: activity.deal_title, + } : null, + assignedTo: activity.assigned_to ? { + id: activity.assigned_to, + name: activity.assigned_name, + } : null, + createdBy: activity.created_by ? { + id: activity.created_by, + name: activity.creator_name, + } : null, + createdAt: activity.created_at, + updatedAt: activity.updated_at, + }; +} + export { router as activitiesRouter };