From 45671f6717ae512668a23db6696cb0055796ca96 Mon Sep 17 00:00:00 2001 From: Flux_bot Date: Wed, 25 Feb 2026 13:02:04 +0000 Subject: [PATCH] feat: Add inbox system for tasks, appointments, emails and reminders - Add inbox router with CRUD endpoints - Add inbox stats endpoint - Add team inbox overview for managers - Support task assignment to team members - Add database migration for inbox_items table --- src/db/migrations/001_inbox_items.sql | 57 +++ src/main.ts | 13 + src/routes/inbox.ts | 603 ++++++++++++++++++++++++++ 3 files changed, 673 insertions(+) create mode 100644 src/db/migrations/001_inbox_items.sql create mode 100644 src/routes/inbox.ts diff --git a/src/db/migrations/001_inbox_items.sql b/src/db/migrations/001_inbox_items.sql new file mode 100644 index 0000000..b5f7607 --- /dev/null +++ b/src/db/migrations/001_inbox_items.sql @@ -0,0 +1,57 @@ +-- Migration: Add inbox_items table +-- Date: 2026-02-25 + +-- ============================================ +-- INBOX ITEMS (Tasks, Appointments, Emails, Reminders) +-- ============================================ +CREATE TABLE IF NOT EXISTS inbox_items ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Item Info + type VARCHAR(50) NOT NULL, -- task, appointment, email, reminder + title VARCHAR(255) NOT NULL, + description TEXT, + + -- Scheduling + due_date TIMESTAMP WITH TIME ZONE, + + -- Priority & Status + priority VARCHAR(20) DEFAULT 'medium', -- low, medium, high, urgent + status VARCHAR(20) DEFAULT 'pending', -- pending, in_progress, done, cancelled + + -- Assignment (for tasks assigned by supervisors) + assigned_by UUID REFERENCES users(id) ON DELETE SET NULL, + + -- Relations (optional links to other entities) + related_contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL, + related_deal_id UUID REFERENCES deals(id) ON DELETE SET NULL, + related_company_id UUID REFERENCES companies(id) ON DELETE SET NULL, + + -- Flexible metadata (for emails: sender, subject, etc.) + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Indexes for performance +CREATE INDEX idx_inbox_items_org_id ON inbox_items(org_id); +CREATE INDEX idx_inbox_items_user_id ON inbox_items(user_id); +CREATE INDEX idx_inbox_items_type ON inbox_items(type); +CREATE INDEX idx_inbox_items_status ON inbox_items(status); +CREATE INDEX idx_inbox_items_priority ON inbox_items(priority); +CREATE INDEX idx_inbox_items_due_date ON inbox_items(due_date); +CREATE INDEX idx_inbox_items_assigned_by ON inbox_items(assigned_by); +CREATE INDEX idx_inbox_items_user_status ON inbox_items(user_id, status); +CREATE INDEX idx_inbox_items_due_today ON inbox_items(user_id, due_date) + WHERE status NOT IN ('done', 'cancelled'); + +-- Auto-update trigger +CREATE TRIGGER update_inbox_items_updated_at BEFORE UPDATE ON inbox_items + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Add deleted_at column to users if not exists (for soft delete check) +ALTER TABLE users ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE; diff --git a/src/main.ts b/src/main.ts index acf83f2..739990d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import "@std/dotenv/load"; // Routes import { authRouter } from "./routes/auth.ts"; import { usersRouter } from "./routes/users.ts"; +import inboxRouter from "./routes/inbox.ts"; import { contactsRouter } from "./routes/contacts.ts"; import { companiesRouter } from "./routes/companies.ts"; import { dealsRouter } from "./routes/deals.ts"; @@ -148,6 +149,15 @@ app.use(async (ctx, next) => { "DELETE /api/v1/users/:id": "Delete user (admin/owner)", "POST /api/v1/users/:id/reset-password": "Reset user password (admin/owner)", }, + inbox: { + "GET /api/v1/inbox": "List inbox items (tasks, appointments, emails)", + "GET /api/v1/inbox/stats": "Inbox statistics", + "GET /api/v1/inbox/team": "Team inbox overview (manager+)", + "POST /api/v1/inbox": "Create inbox item / assign task", + "PUT /api/v1/inbox/:id": "Update inbox item", + "PUT /api/v1/inbox/:id/status": "Quick status update", + "DELETE /api/v1/inbox/:id": "Delete inbox item", + }, contacts: { "GET /api/v1/contacts": "List contacts", "GET /api/v1/contacts/stats": "Contact statistics", @@ -226,6 +236,9 @@ app.use(authRouter.allowedMethods()); app.use(usersRouter.routes()); app.use(usersRouter.allowedMethods()); +app.use(inboxRouter.routes()); +app.use(inboxRouter.allowedMethods()); + app.use(contactsRouter.routes()); app.use(contactsRouter.allowedMethods()); diff --git a/src/routes/inbox.ts b/src/routes/inbox.ts new file mode 100644 index 0000000..a62ccdb --- /dev/null +++ b/src/routes/inbox.ts @@ -0,0 +1,603 @@ +import { Router, Status } from "https://deno.land/x/oak@v12.6.1/mod.ts"; +import { query, execute, getConnection } from "../db/connection.ts"; +import { authMiddleware, AuthState } from "../middleware/auth.ts"; + +const router = new Router(); + +// ============================================ +// TYPES +// ============================================ + +export type InboxItemType = "task" | "appointment" | "email" | "reminder"; +export type InboxPriority = "low" | "medium" | "high" | "urgent"; +export type InboxStatus = "pending" | "in_progress" | "done" | "cancelled"; + +export interface InboxItem { + id: string; + org_id: string; + user_id: string; + type: InboxItemType; + title: string; + description?: string; + due_date?: Date; + priority: InboxPriority; + status: InboxStatus; + assigned_by?: string; + assigned_by_name?: string; + related_contact_id?: string; + related_deal_id?: string; + related_company_id?: string; + metadata?: Record; + created_at: Date; + updated_at: Date; +} + +// ============================================ +// HELPER FUNCTIONS +// ============================================ + +function generateId(): string { + return crypto.randomUUID(); +} + +// ============================================ +// GET /api/v1/inbox - List inbox items for current user +// ============================================ + +router.get("/api/v1/inbox", authMiddleware, async (ctx) => { + const userId = ctx.state.user.id; + const orgId = ctx.state.orgId; + + const url = new URL(ctx.request.url); + const status = url.searchParams.get("status"); + const type = url.searchParams.get("type"); + const today = url.searchParams.get("today") === "true"; + const limit = parseInt(url.searchParams.get("limit") || "50"); + const offset = parseInt(url.searchParams.get("offset") || "0"); + + let queryStr = ` + SELECT + i.*, + u.first_name || ' ' || u.last_name as assigned_by_name, + c.first_name || ' ' || c.last_name as contact_name, + co.name as company_name, + d.title as deal_title + FROM inbox_items i + LEFT JOIN users u ON i.assigned_by = u.id + LEFT JOIN contacts c ON i.related_contact_id = c.id + LEFT JOIN companies co ON i.related_company_id = co.id + LEFT JOIN deals d ON i.related_deal_id = d.id + WHERE i.user_id = $1 AND i.org_id = $2 + `; + const params: (string | number)[] = [userId, orgId]; + let paramIndex = 3; + + if (status) { + queryStr += ` AND i.status = $${paramIndex}`; + params.push(status); + paramIndex++; + } + + if (type) { + queryStr += ` AND i.type = $${paramIndex}`; + params.push(type); + paramIndex++; + } + + if (today) { + queryStr += ` AND DATE(i.due_date) = CURRENT_DATE`; + } + + queryStr += ` ORDER BY + CASE i.priority + WHEN 'urgent' THEN 1 + WHEN 'high' THEN 2 + WHEN 'medium' THEN 3 + ELSE 4 + END, + i.due_date ASC NULLS LAST, + i.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + params.push(limit, offset); + + try { + const result = await query(queryStr, params); + + // Get count for pagination + let countQuery = ` + SELECT COUNT(*) as total FROM inbox_items i + WHERE i.user_id = $1 AND i.org_id = $2 + `; + const countParams: string[] = [userId, orgId]; + let countParamIndex = 3; + + if (status) { + countQuery += ` AND i.status = $${countParamIndex}`; + countParams.push(status); + countParamIndex++; + } + if (type) { + countQuery += ` AND i.type = $${countParamIndex}`; + countParams.push(type); + countParamIndex++; + } + if (today) { + countQuery += ` AND DATE(i.due_date) = CURRENT_DATE`; + } + + const countResult = await query<{ total: number }>(countQuery, countParams); + const total = Number(countResult[0]?.total || 0); + + ctx.response.body = { + success: true, + data: { + items: result.map(item => ({ + id: item.id, + type: item.type, + title: item.title, + description: item.description, + dueDate: item.due_date, + priority: item.priority, + status: item.status, + assignedBy: item.assigned_by, + assignedByName: item.assigned_by_name, + relatedContactId: item.related_contact_id, + relatedContactName: item.contact_name, + relatedDealId: item.related_deal_id, + relatedDealTitle: item.deal_title, + relatedCompanyId: item.related_company_id, + relatedCompanyName: item.company_name, + metadata: item.metadata, + createdAt: item.created_at, + updatedAt: item.updated_at, + })), + pagination: { + total, + limit, + offset, + hasMore: offset + limit < total, + }, + }, + }; + } catch (error) { + console.error("Error fetching inbox:", error); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { success: false, error: "Failed to fetch inbox items" }; + } +}); + +// ============================================ +// GET /api/v1/inbox/stats - Get inbox statistics +// ============================================ + +router.get("/api/v1/inbox/stats", authMiddleware, async (ctx) => { + const userId = ctx.state.user.id; + const orgId = ctx.state.orgId; + + try { + const result = await query<{ + total: number; + pending: number; + in_progress: number; + done_today: number; + overdue: number; + due_today: number; + }>(` + SELECT + COUNT(*) FILTER (WHERE status != 'done' AND status != 'cancelled') as total, + COUNT(*) FILTER (WHERE status = 'pending') as pending, + COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress, + COUNT(*) FILTER (WHERE status = 'done' AND DATE(updated_at) = CURRENT_DATE) as done_today, + COUNT(*) FILTER (WHERE status NOT IN ('done', 'cancelled') AND due_date < CURRENT_DATE) as overdue, + COUNT(*) FILTER (WHERE status NOT IN ('done', 'cancelled') AND DATE(due_date) = CURRENT_DATE) as due_today + FROM inbox_items + WHERE user_id = $1 AND org_id = $2 + `, [userId, orgId]); + + const stats = result[0] || { + total: 0, + pending: 0, + in_progress: 0, + done_today: 0, + overdue: 0, + due_today: 0, + }; + + ctx.response.body = { + success: true, + data: { + total: Number(stats.total), + pending: Number(stats.pending), + inProgress: Number(stats.in_progress), + doneToday: Number(stats.done_today), + overdue: Number(stats.overdue), + dueToday: Number(stats.due_today), + }, + }; + } catch (error) { + console.error("Error fetching inbox stats:", error); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { success: false, error: "Failed to fetch inbox stats" }; + } +}); + +// ============================================ +// POST /api/v1/inbox - Create new inbox item +// ============================================ + +router.post("/api/v1/inbox", authMiddleware, async (ctx) => { + const body = await ctx.request.body({ type: "json" }).value; + const creatorId = ctx.state.user.id; + const creatorRole = ctx.state.user.role; + const orgId = ctx.state.orgId; + + const { + userId, // target user (if assigning to someone else) + type, + title, + description, + dueDate, + priority = "medium", + relatedContactId, + relatedDealId, + relatedCompanyId, + metadata, + } = body; + + // Validate required fields + if (!type || !title) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { success: false, error: "Type and title are required" }; + return; + } + + // Validate type + const validTypes: InboxItemType[] = ["task", "appointment", "email", "reminder"]; + if (!validTypes.includes(type)) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { success: false, error: "Invalid type" }; + return; + } + + // Determine target user + let targetUserId = creatorId; + let assignedBy: string | null = null; + + // If assigning to another user, check permissions + if (userId && userId !== creatorId) { + // Only owner, admin, manager can assign to others + if (!["owner", "admin", "manager"].includes(creatorRole)) { + ctx.response.status = Status.Forbidden; + ctx.response.body = { success: false, error: "Not authorized to assign tasks to others" }; + return; + } + + // Verify target user exists in same org + const targetUser = await query<{ id: string }>( + "SELECT id FROM users WHERE id = $1 AND org_id = $2 AND deleted_at IS NULL", + [userId, orgId] + ); + + if (targetUser.length === 0) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { success: false, error: "Target user not found" }; + return; + } + + targetUserId = userId; + assignedBy = creatorId; + } + + const id = generateId(); + const now = new Date(); + + try { + await execute(` + INSERT INTO inbox_items ( + id, org_id, user_id, type, title, description, due_date, + priority, status, assigned_by, related_contact_id, + related_deal_id, related_company_id, metadata, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, 'pending', $9, $10, $11, $12, $13, $14, $15 + ) + `, [ + id, + orgId, + targetUserId, + type, + title, + description || null, + dueDate ? new Date(dueDate) : null, + priority, + assignedBy, + relatedContactId || null, + relatedDealId || null, + relatedCompanyId || null, + metadata ? JSON.stringify(metadata) : null, + now, + now, + ]); + + ctx.response.status = Status.Created; + ctx.response.body = { + success: true, + data: { + id, + type, + title, + description, + dueDate, + priority, + status: "pending", + assignedBy, + relatedContactId, + relatedDealId, + relatedCompanyId, + metadata, + createdAt: now, + updatedAt: now, + }, + }; + } catch (error) { + console.error("Error creating inbox item:", error); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { success: false, error: "Failed to create inbox item" }; + } +}); + +// ============================================ +// PUT /api/v1/inbox/:id - Update inbox item +// ============================================ + +router.put("/api/v1/inbox/:id", authMiddleware, async (ctx) => { + const { id } = ctx.params; + const userId = ctx.state.user.id; + const orgId = ctx.state.orgId; + const body = await ctx.request.body({ type: "json" }).value; + + // Verify item belongs to user + const existing = await query( + "SELECT * FROM inbox_items WHERE id = $1 AND user_id = $2 AND org_id = $3", + [id, userId, orgId] + ); + + if (existing.length === 0) { + ctx.response.status = Status.NotFound; + ctx.response.body = { success: false, error: "Inbox item not found" }; + return; + } + + const { + title, + description, + dueDate, + priority, + status, + relatedContactId, + relatedDealId, + relatedCompanyId, + metadata, + } = body; + + const updates: string[] = []; + const params: (string | Date | null)[] = []; + let paramIndex = 1; + + if (title !== undefined) { + updates.push(`title = $${paramIndex++}`); + params.push(title); + } + if (description !== undefined) { + updates.push(`description = $${paramIndex++}`); + params.push(description); + } + if (dueDate !== undefined) { + updates.push(`due_date = $${paramIndex++}`); + params.push(dueDate ? new Date(dueDate) : null); + } + if (priority !== undefined) { + updates.push(`priority = $${paramIndex++}`); + params.push(priority); + } + if (status !== undefined) { + updates.push(`status = $${paramIndex++}`); + params.push(status); + } + if (relatedContactId !== undefined) { + updates.push(`related_contact_id = $${paramIndex++}`); + params.push(relatedContactId); + } + if (relatedDealId !== undefined) { + updates.push(`related_deal_id = $${paramIndex++}`); + params.push(relatedDealId); + } + if (relatedCompanyId !== undefined) { + updates.push(`related_company_id = $${paramIndex++}`); + params.push(relatedCompanyId); + } + if (metadata !== undefined) { + updates.push(`metadata = $${paramIndex++}`); + params.push(metadata ? JSON.stringify(metadata) : null); + } + + if (updates.length === 0) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { success: false, error: "No fields to update" }; + return; + } + + updates.push(`updated_at = $${paramIndex++}`); + params.push(new Date()); + + params.push(id); + + try { + await execute( + `UPDATE inbox_items SET ${updates.join(", ")} WHERE id = $${paramIndex}`, + params + ); + + ctx.response.body = { success: true, message: "Inbox item updated" }; + } catch (error) { + console.error("Error updating inbox item:", error); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { success: false, error: "Failed to update inbox item" }; + } +}); + +// ============================================ +// PUT /api/v1/inbox/:id/status - Quick status update +// ============================================ + +router.put("/api/v1/inbox/:id/status", authMiddleware, async (ctx) => { + const { id } = ctx.params; + const userId = ctx.state.user.id; + const orgId = ctx.state.orgId; + const body = await ctx.request.body({ type: "json" }).value; + const { status } = body; + + const validStatuses: InboxStatus[] = ["pending", "in_progress", "done", "cancelled"]; + if (!validStatuses.includes(status)) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { success: false, error: "Invalid status" }; + return; + } + + try { + const rowCount = await execute( + `UPDATE inbox_items SET status = $1, updated_at = $2 + WHERE id = $3 AND user_id = $4 AND org_id = $5`, + [status, new Date(), id, userId, orgId] + ); + + if (rowCount === 0) { + ctx.response.status = Status.NotFound; + ctx.response.body = { success: false, error: "Inbox item not found" }; + return; + } + + ctx.response.body = { success: true, message: "Status updated" }; + } catch (error) { + console.error("Error updating status:", error); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { success: false, error: "Failed to update status" }; + } +}); + +// ============================================ +// DELETE /api/v1/inbox/:id - Delete inbox item +// ============================================ + +router.delete("/api/v1/inbox/:id", authMiddleware, async (ctx) => { + const { id } = ctx.params; + const userId = ctx.state.user.id; + const orgId = ctx.state.orgId; + + try { + const rowCount = await execute( + "DELETE FROM inbox_items WHERE id = $1 AND user_id = $2 AND org_id = $3", + [id, userId, orgId] + ); + + if (rowCount === 0) { + ctx.response.status = Status.NotFound; + ctx.response.body = { success: false, error: "Inbox item not found" }; + return; + } + + ctx.response.body = { success: true, message: "Inbox item deleted" }; + } catch (error) { + console.error("Error deleting inbox item:", error); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { success: false, error: "Failed to delete inbox item" }; + } +}); + +// ============================================ +// GET /api/v1/inbox/team - List inbox items for team (manager+) +// ============================================ + +router.get("/api/v1/inbox/team", authMiddleware, async (ctx) => { + const userRole = ctx.state.user.role; + const orgId = ctx.state.orgId; + + if (!["owner", "admin", "manager"].includes(userRole)) { + ctx.response.status = Status.Forbidden; + ctx.response.body = { success: false, error: "Not authorized" }; + return; + } + + const url = new URL(ctx.request.url); + const userId = url.searchParams.get("userId"); + const status = url.searchParams.get("status"); + const limit = parseInt(url.searchParams.get("limit") || "50"); + + let queryStr = ` + SELECT + i.*, + u.first_name || ' ' || u.last_name as user_name, + u.email as user_email, + au.first_name || ' ' || au.last_name as assigned_by_name + FROM inbox_items i + JOIN users u ON i.user_id = u.id + LEFT JOIN users au ON i.assigned_by = au.id + WHERE i.org_id = $1 + `; + const params: (string | number)[] = [orgId]; + let paramIndex = 2; + + if (userId) { + queryStr += ` AND i.user_id = $${paramIndex}`; + params.push(userId); + paramIndex++; + } + + if (status) { + queryStr += ` AND i.status = $${paramIndex}`; + params.push(status); + paramIndex++; + } + + queryStr += ` ORDER BY i.due_date ASC NULLS LAST, i.created_at DESC LIMIT $${paramIndex}`; + params.push(limit); + + try { + const result = await query(queryStr, params); + + ctx.response.body = { + success: true, + data: result.map(item => ({ + id: item.id, + userId: item.user_id, + userName: item.user_name, + userEmail: item.user_email, + type: item.type, + title: item.title, + description: item.description, + dueDate: item.due_date, + priority: item.priority, + status: item.status, + assignedBy: item.assigned_by, + assignedByName: item.assigned_by_name, + createdAt: item.created_at, + updatedAt: item.updated_at, + })), + }; + } catch (error) { + console.error("Error fetching team inbox:", error); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { success: false, error: "Failed to fetch team inbox" }; + } +}); + +export default router;