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:
@@ -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",
|
||||
|
||||
457
src/repositories/activity.ts
Normal file
457
src/repositories/activity.ts
Normal 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"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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: 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 },
|
||||
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 }
|
||||
);
|
||||
|
||||
// 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 };
|
||||
|
||||
Reference in New Issue
Block a user