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:
57
src/db/migrations/001_inbox_items.sql
Normal file
57
src/db/migrations/001_inbox_items.sql
Normal 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;
|
||||||
13
src/main.ts
13
src/main.ts
@@ -4,6 +4,7 @@ import "@std/dotenv/load";
|
|||||||
// Routes
|
// Routes
|
||||||
import { authRouter } from "./routes/auth.ts";
|
import { authRouter } from "./routes/auth.ts";
|
||||||
import { usersRouter } from "./routes/users.ts";
|
import { usersRouter } from "./routes/users.ts";
|
||||||
|
import inboxRouter from "./routes/inbox.ts";
|
||||||
import { contactsRouter } from "./routes/contacts.ts";
|
import { contactsRouter } from "./routes/contacts.ts";
|
||||||
import { companiesRouter } from "./routes/companies.ts";
|
import { companiesRouter } from "./routes/companies.ts";
|
||||||
import { dealsRouter } from "./routes/deals.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)",
|
"DELETE /api/v1/users/:id": "Delete user (admin/owner)",
|
||||||
"POST /api/v1/users/:id/reset-password": "Reset user password (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: {
|
contacts: {
|
||||||
"GET /api/v1/contacts": "List contacts",
|
"GET /api/v1/contacts": "List contacts",
|
||||||
"GET /api/v1/contacts/stats": "Contact statistics",
|
"GET /api/v1/contacts/stats": "Contact statistics",
|
||||||
@@ -226,6 +236,9 @@ app.use(authRouter.allowedMethods());
|
|||||||
app.use(usersRouter.routes());
|
app.use(usersRouter.routes());
|
||||||
app.use(usersRouter.allowedMethods());
|
app.use(usersRouter.allowedMethods());
|
||||||
|
|
||||||
|
app.use(inboxRouter.routes());
|
||||||
|
app.use(inboxRouter.allowedMethods());
|
||||||
|
|
||||||
app.use(contactsRouter.routes());
|
app.use(contactsRouter.routes());
|
||||||
app.use(contactsRouter.allowedMethods());
|
app.use(contactsRouter.allowedMethods());
|
||||||
|
|
||||||
|
|||||||
603
src/routes/inbox.ts
Normal file
603
src/routes/inbox.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user