feat(activities): Aktivitäten & Timeline implementiert

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
This commit is contained in:
2026-02-11 11:10:36 +00:00
parent 085b83e429
commit 6276aed795
3 changed files with 779 additions and 105 deletions

View File

@@ -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",

View File

@@ -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<ActivityWithRelations>(
`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<ActivityWithRelations[]> {
const column = entityType === "contact" ? "contact_id"
: entityType === "company" ? "company_id"
: "deal_id";
return await query<ActivityWithRelations>(
`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<ActivityWithRelations[]> {
const userCondition = userId ? `AND a.assigned_to = $3` : "";
const params = userId ? [orgId, days, userId] : [orgId, days];
return await query<ActivityWithRelations>(
`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<ActivityWithRelations[]> {
const userCondition = userId ? `AND a.assigned_to = $2` : "";
const params = userId ? [orgId, userId] : [orgId];
return await query<ActivityWithRelations>(
`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<ActivityWithRelations | null> {
const rows = await query<ActivityWithRelations>(
`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<Activity> {
const rows = await query<Activity>(
`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<Activity | null> {
const updates: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
const fieldMap: Record<string, string> = {
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<Activity>(
`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<Activity | null> {
const rows = await query<Activity>(
`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<Activity | null> {
const rows = await query<Activity>(
`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<boolean> {
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<ActivityType, number>;
}> {
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"),
},
};
}

View File

@@ -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<AuthState>({ 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 };