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
This commit is contained in:
2026-02-25 13:02:04 +00:00
parent d0ca0b9d7d
commit 45671f6717
3 changed files with 673 additions and 0 deletions

View File

@@ -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;

View File

@@ -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());

603
src/routes/inbox.ts Normal file
View File

@@ -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<AuthState>();
// ============================================
// 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<string, unknown>;
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<InboxItem & {
assigned_by_name: string;
contact_name: string;
company_name: string;
deal_title: string;
}>(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<InboxItem>(
"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<InboxItem & {
user_name: string;
user_email: string;
assigned_by_name: string;
}>(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;