feat: auth system, scrip scheduler, UI widgets, and new API routes
- Add session-based authentication (login page, middleware, auth context) - Add cron-like scrip scheduler for time-based conditions - Add layout builder, scrip wizard, searchable select components - Add trend chart widget for dashboards - Add notifications, attachments, queue-permissions API routes - Add seed-users script - Update schema with 10 new migrations (0008-0017) - Apply redesign: Linear-inspired dark theme, conversation-centric UI - Gitignore runtime data directory Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,3 @@
|
|||||||
DATABASE_URL=postgres://tessera:password@localhost:5432/tessera
|
DATABASE_URL=postgres://tessera:tessera@127.0.0.1:5435/tessera
|
||||||
SERVER_HOST=127.0.0.1
|
SERVER_HOST=127.0.0.1
|
||||||
SERVER_PORT=9876
|
SERVER_PORT=9876
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -36,3 +36,6 @@ bun.lock
|
|||||||
|
|
||||||
# Codegraph index (MCP tool)
|
# Codegraph index (MCP tool)
|
||||||
.codegraph
|
.codegraph
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
/data
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ tessera/
|
|||||||
- Bun (`nix-shell -p bun` or install globally)
|
- Bun (`nix-shell -p bun` or install globally)
|
||||||
- Node.js 22+ (`nix-shell -p nodejs_22`)
|
- Node.js 22+ (`nix-shell -p nodejs_22`)
|
||||||
- Docker (for PostgreSQL)
|
- Docker (for PostgreSQL)
|
||||||
- PostgreSQL container: `docker run -d --name tessera-db -e POSTGRES_USER=tessera -e POSTGRES_PASSWORD=*** -e POSTGRES_DB=tessera -p 127.0.0.1:5433:5432 postgres:17-alpine`
|
- PostgreSQL container: `docker run -d --name tessera-db -e POSTGRES_USER=tessera -e POSTGRES_PASSWORD=tessera -e POSTGRES_DB=tessera -p 127.0.0.1:5435:5432 postgres:17-alpine`
|
||||||
|
|
||||||
### Start backend
|
### Start backend
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
12
drizzle/migrations/0008_sturdy_prism.sql
Normal file
12
drizzle/migrations/0008_sturdy_prism.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE "transaction_attachments" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"transaction_id" uuid NOT NULL,
|
||||||
|
"filename" text NOT NULL,
|
||||||
|
"mime_type" text DEFAULT 'application/octet-stream' NOT NULL,
|
||||||
|
"size_bytes" integer DEFAULT 0 NOT NULL,
|
||||||
|
"storage_path" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "transaction_attachments" ADD CONSTRAINT "transaction_attachments_transaction_id_transactions_id_fk" FOREIGN KEY ("transaction_id") REFERENCES "public"."transactions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "transaction_attachments_tx_id_idx" ON "transaction_attachments" USING btree ("transaction_id");
|
||||||
1
drizzle/migrations/0009_tiny_lady_vermin.sql
Normal file
1
drizzle/migrations/0009_tiny_lady_vermin.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "transaction_attachments" ALTER COLUMN "transaction_id" DROP NOT NULL;
|
||||||
15
drizzle/migrations/0010_misty_morg.sql
Normal file
15
drizzle/migrations/0010_misty_morg.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE "ticket_links" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"ticket_id" integer NOT NULL,
|
||||||
|
"target_ticket_id" integer NOT NULL,
|
||||||
|
"link_type" text NOT NULL,
|
||||||
|
"creator_id" uuid NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now(),
|
||||||
|
CONSTRAINT "ticket_links_ticket_target_type_unique" UNIQUE("ticket_id","target_ticket_id","link_type")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "ticket_links" ADD CONSTRAINT "ticket_links_ticket_id_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ticket_links" ADD CONSTRAINT "ticket_links_target_ticket_id_tickets_id_fk" FOREIGN KEY ("target_ticket_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ticket_links" ADD CONSTRAINT "ticket_links_creator_id_users_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "ticket_links_ticket_id_idx" ON "ticket_links" USING btree ("ticket_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "ticket_links_target_ticket_id_idx" ON "ticket_links" USING btree ("target_ticket_id");
|
||||||
2
drizzle/migrations/0011_breezy_tyrannus.sql
Normal file
2
drizzle/migrations/0011_breezy_tyrannus.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "password_hash" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "role" text DEFAULT 'staff' NOT NULL;
|
||||||
12
drizzle/migrations/0012_living_photon.sql
Normal file
12
drizzle/migrations/0012_living_photon.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE "queue_permissions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"queue_id" uuid NOT NULL,
|
||||||
|
"team_id" uuid NOT NULL,
|
||||||
|
"right_name" text NOT NULL,
|
||||||
|
CONSTRAINT "queue_permissions_queue_team_right_unique" UNIQUE("queue_id","team_id","right_name")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "queue_permissions" ADD CONSTRAINT "queue_permissions_queue_id_queues_id_fk" FOREIGN KEY ("queue_id") REFERENCES "public"."queues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "queue_permissions" ADD CONSTRAINT "queue_permissions_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "queue_permissions_queue_id_idx" ON "queue_permissions" USING btree ("queue_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "queue_permissions_team_id_idx" ON "queue_permissions" USING btree ("team_id");
|
||||||
12
drizzle/migrations/0013_bored_silvermane.sql
Normal file
12
drizzle/migrations/0013_bored_silvermane.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE "user_permissions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"queue_id" uuid NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"right_name" text NOT NULL,
|
||||||
|
CONSTRAINT "user_permissions_queue_user_right_unique" UNIQUE("queue_id","user_id","right_name")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_permissions" ADD CONSTRAINT "user_permissions_queue_id_queues_id_fk" FOREIGN KEY ("queue_id") REFERENCES "public"."queues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_permissions" ADD CONSTRAINT "user_permissions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "user_permissions_queue_id_idx" ON "user_permissions" USING btree ("queue_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "user_permissions_user_id_idx" ON "user_permissions" USING btree ("user_id");
|
||||||
1
drizzle/migrations/0014_cloudy_siren.sql
Normal file
1
drizzle/migrations/0014_cloudy_siren.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "transactions" ADD COLUMN "time_worked_minutes" integer DEFAULT 0;
|
||||||
15
drizzle/migrations/0015_tense_patch.sql
Normal file
15
drizzle/migrations/0015_tense_patch.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE "notifications" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"ticket_id" integer,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"body" text,
|
||||||
|
"read" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_ticket_id_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "notifications_user_id_idx" ON "notifications" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "notifications_user_read_idx" ON "notifications" USING btree ("user_id","read");
|
||||||
12
drizzle/migrations/0016_famous_maximus.sql
Normal file
12
drizzle/migrations/0016_famous_maximus.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE "api_tokens" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"token_hash" text NOT NULL,
|
||||||
|
"last_used_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now(),
|
||||||
|
CONSTRAINT "api_tokens_token_hash_unique" UNIQUE("token_hash")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "api_tokens_user_id_idx" ON "api_tokens" USING btree ("user_id");
|
||||||
1
drizzle/migrations/0017_redundant_the_renegades.sql
Normal file
1
drizzle/migrations/0017_redundant_the_renegades.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "scrips" ADD COLUMN "applicable_trans_types" text;
|
||||||
1418
drizzle/migrations/meta/0008_snapshot.json
Normal file
1418
drizzle/migrations/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1418
drizzle/migrations/meta/0009_snapshot.json
Normal file
1418
drizzle/migrations/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1550
drizzle/migrations/meta/0010_snapshot.json
Normal file
1550
drizzle/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1563
drizzle/migrations/meta/0011_snapshot.json
Normal file
1563
drizzle/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1669
drizzle/migrations/meta/0012_snapshot.json
Normal file
1669
drizzle/migrations/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1775
drizzle/migrations/meta/0013_snapshot.json
Normal file
1775
drizzle/migrations/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1782
drizzle/migrations/meta/0014_snapshot.json
Normal file
1782
drizzle/migrations/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1910
drizzle/migrations/meta/0015_snapshot.json
Normal file
1910
drizzle/migrations/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1999
drizzle/migrations/meta/0016_snapshot.json
Normal file
1999
drizzle/migrations/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2005
drizzle/migrations/meta/0017_snapshot.json
Normal file
2005
drizzle/migrations/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,76 @@
|
|||||||
"when": 1781009018666,
|
"when": 1781009018666,
|
||||||
"tag": "0007_flimsy_roughhouse",
|
"tag": "0007_flimsy_roughhouse",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781039674211,
|
||||||
|
"tag": "0008_sturdy_prism",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781039770418,
|
||||||
|
"tag": "0009_tiny_lady_vermin",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781040536590,
|
||||||
|
"tag": "0010_misty_morg",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781042321413,
|
||||||
|
"tag": "0011_breezy_tyrannus",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781043175153,
|
||||||
|
"tag": "0012_living_photon",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781043729230,
|
||||||
|
"tag": "0013_bored_silvermane",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781045611610,
|
||||||
|
"tag": "0014_cloudy_siren",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781078349499,
|
||||||
|
"tag": "0015_tense_patch",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781078511943,
|
||||||
|
"tag": "0016_famous_maximus",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781095552496,
|
||||||
|
"tag": "0017_redundant_the_renegades",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
|
"jose": "^6.2.3",
|
||||||
"nodemailer": "^8.0.10"
|
"nodemailer": "^8.0.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
scripts/seed-users.ts
Normal file
28
scripts/seed-users.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { users } from '../src/db/schema.ts';
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL! });
|
||||||
|
const db = drizzle(pool);
|
||||||
|
|
||||||
|
const BATCH = 100;
|
||||||
|
const TOTAL = 1000;
|
||||||
|
const password = await Bun.password.hash('password');
|
||||||
|
|
||||||
|
console.log(`Inserting ${TOTAL} users...`);
|
||||||
|
for (let i = 0; i < TOTAL; i += BATCH) {
|
||||||
|
const batch = [];
|
||||||
|
for (let j = i; j < Math.min(i + BATCH, TOTAL); j++) {
|
||||||
|
const n = String(j).padStart(4, '0');
|
||||||
|
batch.push({
|
||||||
|
username: `user${n}`,
|
||||||
|
email: `user${n}@test.local`,
|
||||||
|
role: 'staff',
|
||||||
|
password_hash: password,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await db.insert(users).values(batch as any).onConflictDoNothing();
|
||||||
|
process.stdout.write('.');
|
||||||
|
}
|
||||||
|
console.log(`\nDone. ${TOTAL} users seeded.`);
|
||||||
|
await pool.end();
|
||||||
144
src/auth/middleware.ts
Normal file
144
src/auth/middleware.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import type { Context, Next } from 'hono';
|
||||||
|
import { HTTPException } from 'hono/http-exception';
|
||||||
|
import * as jose from 'jose';
|
||||||
|
import { config } from '../config.ts';
|
||||||
|
import type { Db } from '../db/index.ts';
|
||||||
|
import { users, apiTokens } from '../db/schema.ts';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'hono' {
|
||||||
|
interface ContextVariableMap {
|
||||||
|
user: AuthUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = new TextEncoder().encode(config.JWT_SECRET);
|
||||||
|
|
||||||
|
export async function createToken(user: { id: string; username: string; role: string }): Promise<string> {
|
||||||
|
return await new jose.SignJWT({ username: user.username, role: user.role })
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setSubject(user.id)
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('7d')
|
||||||
|
.sign(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyJwt(token: string): Promise<AuthUser | null> {
|
||||||
|
try {
|
||||||
|
const { payload } = await jose.jwtVerify(token, secret);
|
||||||
|
return {
|
||||||
|
userId: payload.sub!,
|
||||||
|
username: payload.username as string,
|
||||||
|
role: payload.role as string,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyApiToken(db: Db, token: string): Promise<AuthUser | null> {
|
||||||
|
try {
|
||||||
|
// Find all tokens and verify against hash
|
||||||
|
const allTokens = await db.query.apiTokens.findMany();
|
||||||
|
for (const t of allTokens) {
|
||||||
|
const valid = await Bun.password.verify(token, t.token_hash);
|
||||||
|
if (valid) {
|
||||||
|
// Update last_used_at
|
||||||
|
await db.update(apiTokens)
|
||||||
|
.set({ last_used_at: new Date() } as any)
|
||||||
|
.where(eq(apiTokens.id, t.id));
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, t.user_id),
|
||||||
|
});
|
||||||
|
if (user) {
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractToken(c: Context): string | null {
|
||||||
|
const auth = c.req.header('Authorization');
|
||||||
|
if (auth?.startsWith('Bearer ')) {
|
||||||
|
return auth.slice(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie = c.req.header('Cookie');
|
||||||
|
if (cookie) {
|
||||||
|
const match = cookie.match(/(?:^|;\s*)token=([^;]*)/);
|
||||||
|
if (match?.[1]) return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuthMiddleware(db: Db) {
|
||||||
|
async function verifyToken(token: string): Promise<AuthUser | null> {
|
||||||
|
if (token.startsWith('tessera_')) {
|
||||||
|
return await verifyApiToken(db, token);
|
||||||
|
}
|
||||||
|
return await verifyJwt(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireAuth(c: Context, next: Next) {
|
||||||
|
const token = extractToken(c);
|
||||||
|
if (!token) {
|
||||||
|
throw new HTTPException(401, { message: 'Authentication required' });
|
||||||
|
}
|
||||||
|
const user = await verifyToken(token);
|
||||||
|
if (!user) {
|
||||||
|
throw new HTTPException(401, { message: 'Invalid or expired token' });
|
||||||
|
}
|
||||||
|
c.set('user', user);
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireAdmin(c: Context, next: Next) {
|
||||||
|
const token = extractToken(c);
|
||||||
|
if (!token) {
|
||||||
|
throw new HTTPException(401, { message: 'Authentication required' });
|
||||||
|
}
|
||||||
|
const user = await verifyToken(token);
|
||||||
|
if (!user) {
|
||||||
|
throw new HTTPException(401, { message: 'Invalid or expired token' });
|
||||||
|
}
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
throw new HTTPException(403, { message: 'Admin access required' });
|
||||||
|
}
|
||||||
|
c.set('user', user);
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optionalAuth(c: Context, next: Next) {
|
||||||
|
const token = extractToken(c);
|
||||||
|
if (token) {
|
||||||
|
const user = await verifyToken(token);
|
||||||
|
if (user) {
|
||||||
|
c.set('user', user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requireAuth, requireAdmin, optionalAuth };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserId(c: Context): string {
|
||||||
|
const user = c.get('user');
|
||||||
|
return user?.userId ?? '00000000-0000-0000-0000-000000000000';
|
||||||
|
}
|
||||||
86
src/auth/permissions.ts
Normal file
86
src/auth/permissions.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { HTTPException } from 'hono/http-exception';
|
||||||
|
import type { Context } from 'hono';
|
||||||
|
import type { Db } from '../db/index.ts';
|
||||||
|
import { teamMembers, queuePermissions, userPermissions } from '../db/schema.ts';
|
||||||
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
|
import type { AuthUser } from './middleware.ts';
|
||||||
|
|
||||||
|
export type TicketRight = 'ticket.view' | 'ticket.create' | 'ticket.reply' | 'ticket.comment' | 'ticket.modify' | 'queue.admin';
|
||||||
|
|
||||||
|
const RIGHT_HIERARCHY: Record<TicketRight, TicketRight[]> = {
|
||||||
|
'queue.admin': ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify', 'queue.admin'],
|
||||||
|
'ticket.modify': ['ticket.view', 'ticket.reply', 'ticket.comment', 'ticket.modify'],
|
||||||
|
'ticket.reply': ['ticket.view', 'ticket.reply'],
|
||||||
|
'ticket.comment': ['ticket.view', 'ticket.comment'],
|
||||||
|
'ticket.create': ['ticket.create'],
|
||||||
|
'ticket.view': ['ticket.view'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a user has a specific right on a queue.
|
||||||
|
* Admins bypass all permission checks.
|
||||||
|
* Rights come from two sources: team memberships and per-user grants.
|
||||||
|
* Higher rights imply lower rights (e.g., queue.admin implies ticket.view).
|
||||||
|
*/
|
||||||
|
export async function userHasRight(
|
||||||
|
db: Db,
|
||||||
|
user: AuthUser,
|
||||||
|
queueId: string,
|
||||||
|
right: TicketRight,
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Admins have all rights
|
||||||
|
if (user.role === 'admin') return true;
|
||||||
|
|
||||||
|
const neededRights = RIGHT_HIERARCHY[right] ?? [right];
|
||||||
|
|
||||||
|
// Check per-user permissions first (direct grant)
|
||||||
|
const userPerm = await db.query.userPermissions.findFirst({
|
||||||
|
where: (table, { and, eq: eqFn, inArray: inArr }) =>
|
||||||
|
and(
|
||||||
|
eqFn(table.user_id, user.userId),
|
||||||
|
eqFn(table.queue_id, queueId),
|
||||||
|
inArr(table.right_name, neededRights),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userPerm) return true;
|
||||||
|
|
||||||
|
// Check team permissions (inherited)
|
||||||
|
const memberships = await db.query.teamMembers.findMany({
|
||||||
|
where: eq(teamMembers.user_id, user.userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamIds = memberships.map((m) => m.team_id);
|
||||||
|
if (teamIds.length === 0) return false;
|
||||||
|
|
||||||
|
const teamPerm = await db.query.queuePermissions.findFirst({
|
||||||
|
where: (table, { and, eq: eqFn, inArray: inArr }) =>
|
||||||
|
and(
|
||||||
|
inArr(table.team_id, teamIds),
|
||||||
|
eqFn(table.queue_id, queueId),
|
||||||
|
inArr(table.right_name, neededRights),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return teamPerm !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require a specific right on a queue. Throws 403 if the user lacks the right.
|
||||||
|
*/
|
||||||
|
export async function requireRight(
|
||||||
|
c: Context,
|
||||||
|
db: Db,
|
||||||
|
queueId: string,
|
||||||
|
right: TicketRight,
|
||||||
|
): Promise<void> {
|
||||||
|
const user = c.get('user');
|
||||||
|
if (!user) {
|
||||||
|
throw new HTTPException(401, { message: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const has = await userHasRight(db, user, queueId, right);
|
||||||
|
if (!has) {
|
||||||
|
throw new HTTPException(403, { message: `Missing required right: ${right}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ const configSchema = z.object({
|
|||||||
SMTP_USER: z.string().optional(),
|
SMTP_USER: z.string().optional(),
|
||||||
SMTP_PASS: z.string().optional(),
|
SMTP_PASS: z.string().optional(),
|
||||||
SMTP_FROM: z.string().default('tessera@localhost'),
|
SMTP_FROM: z.string().default('tessera@localhost'),
|
||||||
|
UPLOAD_DIR: z.string().default('./data/uploads'),
|
||||||
|
JWT_SECRET: z.string().default('tessera-dev-secret-change-in-production'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const config = configSchema.parse(process.env);
|
export const config = configSchema.parse(process.env);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export const users = pgTable('users', {
|
|||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
username: text('username').notNull().unique(),
|
username: text('username').notNull().unique(),
|
||||||
email: text('email'),
|
email: text('email'),
|
||||||
|
password_hash: text('password_hash'),
|
||||||
|
role: text('role').notNull().default('staff'),
|
||||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ export const transactions = pgTable('transactions', {
|
|||||||
old_value: text('old_value'),
|
old_value: text('old_value'),
|
||||||
new_value: text('new_value'),
|
new_value: text('new_value'),
|
||||||
data: jsonb('data'),
|
data: jsonb('data'),
|
||||||
|
time_worked_minutes: integer('time_worked_minutes').default(0),
|
||||||
creator_id: uuid('creator_id').notNull().references(() => users.id),
|
creator_id: uuid('creator_id').notNull().references(() => users.id),
|
||||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
@@ -78,6 +81,7 @@ export const scrips = pgTable('scrips', {
|
|||||||
stage: text('stage').notNull().default('TransactionCreate'),
|
stage: text('stage').notNull().default('TransactionCreate'),
|
||||||
sort_order: integer('sort_order').notNull().default(0),
|
sort_order: integer('sort_order').notNull().default(0),
|
||||||
disabled: boolean('disabled').notNull().default(false),
|
disabled: boolean('disabled').notNull().default(false),
|
||||||
|
applicable_trans_types: text('applicable_trans_types'),
|
||||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
queueIdIdx: index('scrips_queue_id_idx').on(table.queue_id),
|
queueIdIdx: index('scrips_queue_id_idx').on(table.queue_id),
|
||||||
@@ -160,6 +164,78 @@ export const dashboards = pgTable('dashboards', {
|
|||||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const transactionAttachments = pgTable('transaction_attachments', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
transaction_id: uuid('transaction_id').references(() => transactions.id, { onDelete: 'cascade' }),
|
||||||
|
filename: text('filename').notNull(),
|
||||||
|
mime_type: text('mime_type').notNull().default('application/octet-stream'),
|
||||||
|
size_bytes: integer('size_bytes').notNull().default(0),
|
||||||
|
storage_path: text('storage_path').notNull(),
|
||||||
|
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
|
}, (table) => ({
|
||||||
|
transactionIdIdx: index('transaction_attachments_tx_id_idx').on(table.transaction_id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const queuePermissions = pgTable('queue_permissions', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
queue_id: uuid('queue_id').notNull().references(() => queues.id, { onDelete: 'cascade' }),
|
||||||
|
team_id: uuid('team_id').notNull().references(() => teams.id, { onDelete: 'cascade' }),
|
||||||
|
right_name: text('right_name').notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
uniqueRight: unique('queue_permissions_queue_team_right_unique').on(table.queue_id, table.team_id, table.right_name),
|
||||||
|
queueIdIdx: index('queue_permissions_queue_id_idx').on(table.queue_id),
|
||||||
|
teamIdIdx: index('queue_permissions_team_id_idx').on(table.team_id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const apiTokens = pgTable('api_tokens', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
token_hash: text('token_hash').notNull().unique(),
|
||||||
|
last_used_at: timestamp('last_used_at', { withTimezone: true }),
|
||||||
|
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
|
}, (table) => ({
|
||||||
|
userIdIdx: index('api_tokens_user_id_idx').on(table.user_id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const notifications = pgTable('notifications', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
ticket_id: integer('ticket_id').references(() => tickets.id, { onDelete: 'cascade' }),
|
||||||
|
type: text('type').notNull(), // 'assigned', 'mentioned', 'commented', 'scrip_fired'
|
||||||
|
title: text('title').notNull(),
|
||||||
|
body: text('body'),
|
||||||
|
read: boolean('read').notNull().default(false),
|
||||||
|
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
|
}, (table) => ({
|
||||||
|
userIdIdx: index('notifications_user_id_idx').on(table.user_id),
|
||||||
|
unreadIdx: index('notifications_user_read_idx').on(table.user_id, table.read),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const userPermissions = pgTable('user_permissions', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
queue_id: uuid('queue_id').notNull().references(() => queues.id, { onDelete: 'cascade' }),
|
||||||
|
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
right_name: text('right_name').notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
uniqueRight: unique('user_permissions_queue_user_right_unique').on(table.queue_id, table.user_id, table.right_name),
|
||||||
|
queueIdIdx: index('user_permissions_queue_id_idx').on(table.queue_id),
|
||||||
|
userIdIdx: index('user_permissions_user_id_idx').on(table.user_id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const ticketLinks = pgTable('ticket_links', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
ticket_id: integer('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
|
||||||
|
target_ticket_id: integer('target_ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
|
||||||
|
link_type: text('link_type').notNull(),
|
||||||
|
creator_id: uuid('creator_id').notNull().references(() => users.id),
|
||||||
|
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
|
}, (table) => ({
|
||||||
|
uniqueLink: unique('ticket_links_ticket_target_type_unique').on(table.ticket_id, table.target_ticket_id, table.link_type),
|
||||||
|
ticketIdIdx: index('ticket_links_ticket_id_idx').on(table.ticket_id),
|
||||||
|
targetTicketIdIdx: index('ticket_links_target_ticket_id_idx').on(table.target_ticket_id),
|
||||||
|
}));
|
||||||
|
|
||||||
export const dashboardWidgets = pgTable('dashboard_widgets', {
|
export const dashboardWidgets = pgTable('dashboard_widgets', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
dashboard_id: uuid('dashboard_id').notNull().references(() => dashboards.id, { onDelete: 'cascade' }),
|
dashboard_id: uuid('dashboard_id').notNull().references(() => dashboards.id, { onDelete: 'cascade' }),
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ import {
|
|||||||
customFieldValues,
|
customFieldValues,
|
||||||
lifecycles,
|
lifecycles,
|
||||||
queueCustomFields,
|
queueCustomFields,
|
||||||
|
queuePermissions,
|
||||||
queues,
|
queues,
|
||||||
scrips,
|
scrips,
|
||||||
|
teamMembers,
|
||||||
|
teams,
|
||||||
templates,
|
templates,
|
||||||
tickets,
|
tickets,
|
||||||
transactions,
|
transactions,
|
||||||
@@ -52,7 +55,7 @@ function createSeedDb(pool: Pool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Db = ReturnType<typeof createSeedDb>;
|
type Db = ReturnType<typeof createSeedDb>;
|
||||||
type UserSeed = { id: string; username: string; email: string };
|
type UserSeed = { id: string; username: string; email: string; role?: string; password_hash?: string };
|
||||||
type QueueSeed = { name: string; description: string };
|
type QueueSeed = { name: string; description: string };
|
||||||
type FieldSeed = {
|
type FieldSeed = {
|
||||||
key?: string;
|
key?: string;
|
||||||
@@ -73,12 +76,19 @@ function makeFieldKey(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ensureUser(db: Db, seed: UserSeed): Promise<string> {
|
async function ensureUser(db: Db, seed: UserSeed): Promise<string> {
|
||||||
|
const setData = {
|
||||||
|
username: seed.username,
|
||||||
|
email: seed.email,
|
||||||
|
role: seed.role ?? 'staff',
|
||||||
|
password_hash: seed.password_hash ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
const existingById = await db.query.users.findFirst({
|
const existingById = await db.query.users.findFirst({
|
||||||
where: eq(users.id, seed.id),
|
where: eq(users.id, seed.id),
|
||||||
});
|
});
|
||||||
if (existingById) {
|
if (existingById) {
|
||||||
await db.update(users)
|
await db.update(users)
|
||||||
.set({ username: seed.username, email: seed.email })
|
.set(setData)
|
||||||
.where(eq(users.id, seed.id));
|
.where(eq(users.id, seed.id));
|
||||||
return existingById.id;
|
return existingById.id;
|
||||||
}
|
}
|
||||||
@@ -88,12 +98,12 @@ async function ensureUser(db: Db, seed: UserSeed): Promise<string> {
|
|||||||
});
|
});
|
||||||
if (existingByUsername) {
|
if (existingByUsername) {
|
||||||
await db.update(users)
|
await db.update(users)
|
||||||
.set({ email: seed.email })
|
.set(setData)
|
||||||
.where(eq(users.id, existingByUsername.id));
|
.where(eq(users.id, existingByUsername.id));
|
||||||
return existingByUsername.id;
|
return existingByUsername.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [created] = await db.insert(users).values(seed).returning();
|
const [created] = await db.insert(users).values({ ...seed, ...setData }).returning();
|
||||||
if (!created) throw new Error(`Failed to seed user ${seed.username}`);
|
if (!created) throw new Error(`Failed to seed user ${seed.username}`);
|
||||||
return created.id;
|
return created.id;
|
||||||
}
|
}
|
||||||
@@ -315,6 +325,7 @@ async function ensureTicket(
|
|||||||
|
|
||||||
async function resetDatabase(db: Db) {
|
async function resetDatabase(db: Db) {
|
||||||
await db.delete(customFieldValues);
|
await db.delete(customFieldValues);
|
||||||
|
await db.delete(queuePermissions);
|
||||||
await db.delete(transactions);
|
await db.delete(transactions);
|
||||||
await db.delete(queueCustomFields);
|
await db.delete(queueCustomFields);
|
||||||
await db.delete(dashboardWidgets);
|
await db.delete(dashboardWidgets);
|
||||||
@@ -346,34 +357,68 @@ async function main() {
|
|||||||
await resetDatabase(db);
|
await resetDatabase(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userPassword = await Bun.password.hash('password');
|
||||||
|
const adminPassword = await Bun.password.hash('admin');
|
||||||
|
|
||||||
const userIds = {
|
const userIds = {
|
||||||
system: await ensureUser(db, {
|
system: await ensureUser(db, {
|
||||||
id: SYSTEM_USER_ID,
|
id: SYSTEM_USER_ID,
|
||||||
username: 'system',
|
username: 'system',
|
||||||
email: 'system@tessera.local',
|
email: 'system@tessera.local',
|
||||||
}),
|
}),
|
||||||
|
admin: await ensureUser(db, {
|
||||||
|
id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||||
|
username: 'admin',
|
||||||
|
email: 'admin@tessera.local',
|
||||||
|
role: 'admin',
|
||||||
|
password_hash: adminPassword,
|
||||||
|
}),
|
||||||
dispatcher: await ensureUser(db, {
|
dispatcher: await ensureUser(db, {
|
||||||
id: '11111111-1111-4111-8111-111111111111',
|
id: '11111111-1111-4111-8111-111111111111',
|
||||||
username: 'maria.dispatch',
|
username: 'maria.dispatch',
|
||||||
email: 'maria.dispatch@tessera.local',
|
email: 'maria.dispatch@tessera.local',
|
||||||
|
password_hash: userPassword,
|
||||||
}),
|
}),
|
||||||
technician: await ensureUser(db, {
|
technician: await ensureUser(db, {
|
||||||
id: '22222222-2222-4222-8222-222222222222',
|
id: '22222222-2222-4222-8222-222222222222',
|
||||||
username: 'liam.field',
|
username: 'liam.field',
|
||||||
email: 'liam.field@tessera.local',
|
email: 'liam.field@tessera.local',
|
||||||
|
password_hash: userPassword,
|
||||||
}),
|
}),
|
||||||
facilities: await ensureUser(db, {
|
facilities: await ensureUser(db, {
|
||||||
id: '33333333-3333-4333-8333-333333333333',
|
id: '33333333-3333-4333-8333-333333333333',
|
||||||
username: 'nora.facilities',
|
username: 'nora.facilities',
|
||||||
email: 'nora.facilities@tessera.local',
|
email: 'nora.facilities@tessera.local',
|
||||||
|
password_hash: userPassword,
|
||||||
}),
|
}),
|
||||||
security: await ensureUser(db, {
|
security: await ensureUser(db, {
|
||||||
id: '44444444-4444-4444-8444-444444444444',
|
id: '44444444-4444-4444-8444-444444444444',
|
||||||
username: 'sam.security',
|
username: 'sam.security',
|
||||||
email: 'sam.security@tessera.local',
|
email: 'sam.security@tessera.local',
|
||||||
|
password_hash: userPassword,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Create demo team and assign all staff users to it
|
||||||
|
const [supportTeam] = await db.insert(teams).values({
|
||||||
|
name: 'Support team',
|
||||||
|
description: 'Demo support team with full queue access',
|
||||||
|
}).onConflictDoUpdate({
|
||||||
|
target: teams.name,
|
||||||
|
set: { description: 'Demo support team with full queue access' },
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
if (supportTeam) {
|
||||||
|
// Add all staff users to the team
|
||||||
|
const staffIds = [userIds.dispatcher, userIds.technician, userIds.facilities, userIds.security];
|
||||||
|
for (const userId of staffIds) {
|
||||||
|
await db.insert(teamMembers).values({
|
||||||
|
team_id: supportTeam.id,
|
||||||
|
user_id: userId,
|
||||||
|
}).onConflictDoNothing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const lifecycle = await ensureLifecycle(db);
|
const lifecycle = await ensureLifecycle(db);
|
||||||
|
|
||||||
const supportQueue = await ensureQueue(db, lifecycle.id, {
|
const supportQueue = await ensureQueue(db, lifecycle.id, {
|
||||||
@@ -432,6 +477,20 @@ async function main() {
|
|||||||
await attachFieldToQueue(db, fieldQueue.id, assetField.id, 40);
|
await attachFieldToQueue(db, fieldQueue.id, assetField.id, 40);
|
||||||
await attachFieldToQueue(db, supportQueue.id, outcomeField.id, 50);
|
await attachFieldToQueue(db, supportQueue.id, outcomeField.id, 50);
|
||||||
|
|
||||||
|
// Grant the support team full access to all demo queues
|
||||||
|
if (supportTeam) {
|
||||||
|
const allRights = ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify'];
|
||||||
|
for (const queue of [supportQueue, fieldQueue, facilitiesQueue, securityQueue]) {
|
||||||
|
for (const right of allRights) {
|
||||||
|
await db.insert(queuePermissions).values({
|
||||||
|
queue_id: queue.id,
|
||||||
|
team_id: supportTeam.id,
|
||||||
|
right_name: right,
|
||||||
|
}).onConflictDoNothing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const resolveTemplate = await ensureTemplate(
|
const resolveTemplate = await ensureTemplate(
|
||||||
db,
|
db,
|
||||||
'Demo resolution note',
|
'Demo resolution note',
|
||||||
|
|||||||
51
src/index.ts
51
src/index.ts
@@ -4,6 +4,7 @@ import { createDb } from './db/index.ts';
|
|||||||
import type { Db } from './db/index.ts';
|
import type { Db } from './db/index.ts';
|
||||||
import { errorHandler } from './middleware/error.ts';
|
import { errorHandler } from './middleware/error.ts';
|
||||||
import { requestLogger } from './middleware/logging.ts';
|
import { requestLogger } from './middleware/logging.ts';
|
||||||
|
import { createAuthMiddleware } from './auth/middleware.ts';
|
||||||
import healthRouter from './routes/health.ts';
|
import healthRouter from './routes/health.ts';
|
||||||
import { createTicketsRouter } from './routes/tickets.ts';
|
import { createTicketsRouter } from './routes/tickets.ts';
|
||||||
import { createQueuesRouter } from './routes/queues.ts';
|
import { createQueuesRouter } from './routes/queues.ts';
|
||||||
@@ -15,6 +16,11 @@ import { createTemplatesRouter } from './routes/templates.ts';
|
|||||||
import { createViewsRouter } from './routes/views.ts';
|
import { createViewsRouter } from './routes/views.ts';
|
||||||
import { createDashboardsRouter } from './routes/dashboards.ts';
|
import { createDashboardsRouter } from './routes/dashboards.ts';
|
||||||
import { createTeamsRouter } from './routes/teams.ts';
|
import { createTeamsRouter } from './routes/teams.ts';
|
||||||
|
import { createAttachmentsRouter } from './routes/attachments.ts';
|
||||||
|
import { createAuthRouter } from './routes/auth.ts';
|
||||||
|
import { createQueuePermissionsRouter } from './routes/queue-permissions.ts';
|
||||||
|
import { createNotificationsRouter } from './routes/notifications.ts';
|
||||||
|
import { startScheduler } from './scrip/scheduler.ts';
|
||||||
|
|
||||||
let db: Db | null = null;
|
let db: Db | null = null;
|
||||||
|
|
||||||
@@ -30,17 +36,39 @@ const app = new Hono();
|
|||||||
app.use('*', requestLogger);
|
app.use('*', requestLogger);
|
||||||
app.onError(errorHandler);
|
app.onError(errorHandler);
|
||||||
|
|
||||||
|
const { requireAuth, requireAdmin } = createAuthMiddleware(getDb());
|
||||||
|
|
||||||
|
// Public routes
|
||||||
app.route('/health', healthRouter);
|
app.route('/health', healthRouter);
|
||||||
app.route('/tickets', createTicketsRouter(getDb()));
|
app.route('/', createAuthRouter(getDb()));
|
||||||
app.route('/queues', createQueuesRouter(getDb()));
|
|
||||||
app.route('/scrips', createScripsRouter(getDb()));
|
// Ticket routes — require authentication
|
||||||
app.route('/custom-fields', createCustomFieldsRouter(getDb()));
|
const ticketsWithAuth = new Hono();
|
||||||
app.route('/lifecycles', createLifecyclesRouter(getDb()));
|
ticketsWithAuth.use('*', requireAuth);
|
||||||
app.route('/users', createUsersRouter(getDb()));
|
ticketsWithAuth.route('/tickets', createTicketsRouter(getDb()));
|
||||||
app.route('/templates', createTemplatesRouter(getDb()));
|
ticketsWithAuth.route('/', createNotificationsRouter(getDb()));
|
||||||
app.route('/views', createViewsRouter(getDb()));
|
app.route('/', ticketsWithAuth);
|
||||||
app.route('/dashboards', createDashboardsRouter(getDb()));
|
|
||||||
app.route('/teams', createTeamsRouter(getDb()));
|
// Attachment serving — require authentication
|
||||||
|
const attachmentsWithAuth = new Hono();
|
||||||
|
attachmentsWithAuth.use('*', requireAuth);
|
||||||
|
attachmentsWithAuth.route('/', createAttachmentsRouter(getDb()));
|
||||||
|
app.route('/', attachmentsWithAuth);
|
||||||
|
|
||||||
|
// Admin routes — require admin role
|
||||||
|
const admin = new Hono();
|
||||||
|
admin.use('*', requireAdmin);
|
||||||
|
admin.route('/queues', createQueuesRouter(getDb()));
|
||||||
|
admin.route('/scrips', createScripsRouter(getDb()));
|
||||||
|
admin.route('/custom-fields', createCustomFieldsRouter(getDb()));
|
||||||
|
admin.route('/lifecycles', createLifecyclesRouter(getDb()));
|
||||||
|
admin.route('/users', createUsersRouter(getDb()));
|
||||||
|
admin.route('/templates', createTemplatesRouter(getDb()));
|
||||||
|
admin.route('/views', createViewsRouter(getDb()));
|
||||||
|
admin.route('/dashboards', createDashboardsRouter(getDb()));
|
||||||
|
admin.route('/teams', createTeamsRouter(getDb()));
|
||||||
|
admin.route('/', createQueuePermissionsRouter(getDb()));
|
||||||
|
app.route('/', admin);
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
export { app };
|
export { app };
|
||||||
@@ -54,4 +82,7 @@ if (Bun.main === import.meta.path) {
|
|||||||
development: false,
|
development: false,
|
||||||
});
|
});
|
||||||
console.log(`Server running at http://${config.SERVER_HOST}:${config.SERVER_PORT}`);
|
console.log(`Server running at http://${config.SERVER_HOST}:${config.SERVER_PORT}`);
|
||||||
|
|
||||||
|
// Start the scrip scheduler (runs every 5 minutes)
|
||||||
|
startScheduler(getDb());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ export interface LifecycleDefinition {
|
|||||||
inactive: string[];
|
inactive: string[];
|
||||||
};
|
};
|
||||||
transitions: Record<string, string[]>;
|
transitions: Record<string, string[]>;
|
||||||
|
transition_rights?: Record<string, string>; // "from→to" → rightName
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ValidationResult {
|
export interface ValidationResult {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
requiredRight?: string; // Named right required for this transition, if any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FALLBACK_RIGHT = 'ticket.modify';
|
||||||
|
|
||||||
export class LifecycleValidator {
|
export class LifecycleValidator {
|
||||||
validateTransition(
|
validateTransition(
|
||||||
lifecycleDef: LifecycleDefinition,
|
lifecycleDef: LifecycleDefinition,
|
||||||
@@ -35,13 +39,15 @@ export class LifecycleValidator {
|
|||||||
const allowedTransitions = this.getAllowedTransitions(lifecycleDef, fromStatus);
|
const allowedTransitions = this.getAllowedTransitions(lifecycleDef, fromStatus);
|
||||||
|
|
||||||
if (allowedTransitions.includes(toStatus)) {
|
if (allowedTransitions.includes(toStatus)) {
|
||||||
return { valid: true };
|
const right = this.getRequiredRight(lifecycleDef, fromStatus, toStatus);
|
||||||
|
return { valid: true, requiredRight: right ?? FALLBACK_RIGHT };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also handle wildcard "*" -> any transition
|
// Also handle wildcard "*" -> any transition
|
||||||
const wildcardTransitions = this.getAllowedTransitions(lifecycleDef, '*');
|
const wildcardTransitions = this.getAllowedTransitions(lifecycleDef, '*');
|
||||||
if (wildcardTransitions.includes(toStatus)) {
|
if (wildcardTransitions.includes(toStatus)) {
|
||||||
return { valid: true };
|
const right = this.getRequiredRight(lifecycleDef, fromStatus, toStatus);
|
||||||
|
return { valid: true, requiredRight: right ?? FALLBACK_RIGHT };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -50,6 +56,37 @@ export class LifecycleValidator {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the required right for a transition using RT's 4-level priority:
|
||||||
|
* 1. exact "from→to"
|
||||||
|
* 2. wildcard from "*→to"
|
||||||
|
* 3. wildcard to "from→*"
|
||||||
|
* 4. full wildcard "*→*"
|
||||||
|
* 5. fallback: ticket.modify
|
||||||
|
*/
|
||||||
|
getRequiredRight(
|
||||||
|
lifecycleDef: LifecycleDefinition,
|
||||||
|
fromStatus: string,
|
||||||
|
toStatus: string,
|
||||||
|
): string | null {
|
||||||
|
const rights = lifecycleDef.transition_rights ?? {};
|
||||||
|
|
||||||
|
// Priority 1: exact match
|
||||||
|
if (rights[`${fromStatus}→${toStatus}`]) return rights[`${fromStatus}→${toStatus}`];
|
||||||
|
|
||||||
|
// Priority 2: wildcard from
|
||||||
|
if (rights[`*→${toStatus}`]) return rights[`*→${toStatus}`];
|
||||||
|
|
||||||
|
// Priority 3: wildcard to
|
||||||
|
if (rights[`${fromStatus}→*`]) return rights[`${fromStatus}→*`];
|
||||||
|
|
||||||
|
// Priority 4: full wildcard
|
||||||
|
if (rights['*→*']) return rights['*→*'];
|
||||||
|
|
||||||
|
// Priority 5: fallback
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
isResolvedStatus(lifecycleDef: LifecycleDefinition, status: string): boolean {
|
isResolvedStatus(lifecycleDef: LifecycleDefinition, status: string): boolean {
|
||||||
return lifecycleDef.statuses.inactive.includes(status);
|
return lifecycleDef.statuses.inactive.includes(status);
|
||||||
}
|
}
|
||||||
@@ -58,16 +95,12 @@ export class LifecycleValidator {
|
|||||||
lifecycleDef: LifecycleDefinition,
|
lifecycleDef: LifecycleDefinition,
|
||||||
fromStatus: string,
|
fromStatus: string,
|
||||||
): string[] {
|
): string[] {
|
||||||
// Direct transition
|
|
||||||
if (lifecycleDef.transitions[fromStatus]) {
|
if (lifecycleDef.transitions[fromStatus]) {
|
||||||
return lifecycleDef.transitions[fromStatus]!;
|
return lifecycleDef.transitions[fromStatus]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wildcard transitions
|
|
||||||
if (lifecycleDef.transitions['*']) {
|
if (lifecycleDef.transitions['*']) {
|
||||||
return lifecycleDef.transitions['*']!;
|
return lifecycleDef.transitions['*']!;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,6 @@ export const CommentSchema = z.object({
|
|||||||
body: z.string().min(1),
|
body: z.string().min(1),
|
||||||
creator_id: z.string().optional().default('00000000-0000-0000-0000-000000000000'),
|
creator_id: z.string().optional().default('00000000-0000-0000-0000-000000000000'),
|
||||||
internal: z.boolean().optional().default(false),
|
internal: z.boolean().optional().default(false),
|
||||||
|
attachment_ids: z.array(z.string()).optional(),
|
||||||
|
time_worked_minutes: z.number().int().min(0).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export const TransactionType = {
|
|||||||
Comment: 'Comment',
|
Comment: 'Comment',
|
||||||
CustomField: 'CustomField',
|
CustomField: 'CustomField',
|
||||||
Correspond: 'Correspond',
|
Correspond: 'Correspond',
|
||||||
|
LinkCreate: 'LinkCreate',
|
||||||
|
LinkDelete: 'LinkDelete',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type TransactionType = (typeof TransactionType)[keyof typeof TransactionType];
|
export type TransactionType = (typeof TransactionType)[keyof typeof TransactionType];
|
||||||
|
|||||||
190
src/routes/attachments.ts
Normal file
190
src/routes/attachments.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { HTTPException } from 'hono/http-exception';
|
||||||
|
import { existsSync, mkdirSync, createReadStream } from 'node:fs';
|
||||||
|
import { join, extname } from 'node:path';
|
||||||
|
import { writeFile, unlink } from 'node:fs/promises';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { Db } from '../db/index.ts';
|
||||||
|
import { config } from '../config.ts';
|
||||||
|
import { transactionAttachments, transactions, tickets } from '../db/schema.ts';
|
||||||
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
|
|
||||||
|
function ensureDir(dir: string) {
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageDir(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear().toString();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dir = join(config.UPLOAD_DIR, year, month);
|
||||||
|
ensureDir(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME_MAP: Record<string, string> = {
|
||||||
|
'.txt': 'text/plain',
|
||||||
|
'.html': 'text/html',
|
||||||
|
'.css': 'text/css',
|
||||||
|
'.js': 'application/javascript',
|
||||||
|
'.json': 'application/json',
|
||||||
|
'.xml': 'application/xml',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.zip': 'application/zip',
|
||||||
|
'.gz': 'application/gzip',
|
||||||
|
'.tar': 'application/x-tar',
|
||||||
|
'.csv': 'text/csv',
|
||||||
|
'.doc': 'application/msword',
|
||||||
|
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'.xls': 'application/vnd.ms-excel',
|
||||||
|
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'.ppt': 'application/vnd.ms-powerpoint',
|
||||||
|
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.mp3': 'audio/mpeg',
|
||||||
|
'.wav': 'audio/wav',
|
||||||
|
'.mov': 'video/quicktime',
|
||||||
|
'.avif': 'image/avif',
|
||||||
|
'.heic': 'image/heic',
|
||||||
|
'.heif': 'image/heif',
|
||||||
|
'.log': 'text/plain',
|
||||||
|
'.md': 'text/markdown',
|
||||||
|
'.yaml': 'text/yaml',
|
||||||
|
'.yml': 'text/yaml',
|
||||||
|
};
|
||||||
|
|
||||||
|
function guessMimeType(filename: string): string {
|
||||||
|
const ext = extname(filename).toLowerCase();
|
||||||
|
return MIME_MAP[ext] || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAttachmentsRouter(db: Db): Hono {
|
||||||
|
const router = new Hono();
|
||||||
|
|
||||||
|
// POST /tickets/:id/attachments — upload files (returns metadata, no transaction created yet)
|
||||||
|
router.post('/tickets/:id/attachments', async (c) => {
|
||||||
|
const ticketId = Number(c.req.param('id'));
|
||||||
|
|
||||||
|
const ticket = await db.query.tickets.findFirst({
|
||||||
|
where: eq(tickets.id, ticketId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await c.req.formData();
|
||||||
|
const files = formData.getAll('files') as File[];
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new HTTPException(422, { message: 'No files provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = storageDir();
|
||||||
|
const result: Array<{
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
mime_type: string;
|
||||||
|
size_bytes: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (!(file instanceof File)) continue;
|
||||||
|
|
||||||
|
const ext = extname(file.name);
|
||||||
|
const storedName = `${randomUUID()}${ext}`;
|
||||||
|
const storagePath = join(dir, storedName);
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
await writeFile(storagePath, buffer);
|
||||||
|
|
||||||
|
const [saved] = await db.insert(transactionAttachments).values({
|
||||||
|
filename: file.name,
|
||||||
|
mime_type: file.type || guessMimeType(file.name),
|
||||||
|
size_bytes: buffer.length,
|
||||||
|
storage_path: storagePath,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
if (saved) {
|
||||||
|
result.push({
|
||||||
|
id: saved.id,
|
||||||
|
filename: saved.filename,
|
||||||
|
mime_type: saved.mime_type,
|
||||||
|
size_bytes: saved.size_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ attachments: result }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /attachments/:id — serve/download an attachment
|
||||||
|
router.get('/attachments/:id', async (c) => {
|
||||||
|
const attachmentId = c.req.param('id');
|
||||||
|
|
||||||
|
const attachment = await db.query.transactionAttachments.findFirst({
|
||||||
|
where: eq(transactionAttachments.id, attachmentId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!attachment) {
|
||||||
|
throw new HTTPException(404, { message: 'Attachment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(attachment.storage_path)) {
|
||||||
|
throw new HTTPException(404, { message: 'Attachment file not found on disk' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const disposition = c.req.query('download') === 'true' ? 'attachment' : 'inline';
|
||||||
|
const stream = createReadStream(attachment.storage_path);
|
||||||
|
|
||||||
|
return new Response(stream as any, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': attachment.mime_type,
|
||||||
|
'Content-Disposition': `${disposition}; filename="${encodeURIComponent(attachment.filename)}"`,
|
||||||
|
'Content-Length': String(attachment.size_bytes),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /tickets/:id/attachments — list attachments for a ticket
|
||||||
|
router.get('/tickets/:id/attachments', async (c) => {
|
||||||
|
const ticketId = Number(c.req.param('id'));
|
||||||
|
|
||||||
|
const ticket = await db.query.tickets.findFirst({
|
||||||
|
where: eq(tickets.id, ticketId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketTransactions = await db.query.transactions.findMany({
|
||||||
|
where: eq(transactions.ticket_id, ticketId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const txIds = ticketTransactions.map((tx) => tx.id);
|
||||||
|
if (txIds.length === 0) {
|
||||||
|
return c.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = await Promise.all(
|
||||||
|
txIds.map((txId) =>
|
||||||
|
db.query.transactionAttachments.findMany({
|
||||||
|
where: eq(transactionAttachments.transaction_id, txId),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(attachments.flat());
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
132
src/routes/auth.ts
Normal file
132
src/routes/auth.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { HTTPException } from 'hono/http-exception';
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
import type { Db } from '../db/index.ts';
|
||||||
|
import { users, apiTokens } from '../db/schema.ts';
|
||||||
|
import { eq, desc, sql } from 'drizzle-orm';
|
||||||
|
import { createToken, createAuthMiddleware } from '../auth/middleware.ts';
|
||||||
|
|
||||||
|
const LoginSchema = z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createAuthRouter(db: Db): Hono {
|
||||||
|
const router = new Hono();
|
||||||
|
const { requireAuth } = createAuthMiddleware(db);
|
||||||
|
|
||||||
|
// POST /auth/login
|
||||||
|
router.post('/auth/login', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const parsed = LoginSchema.parse(body);
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.username, parsed.username),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.password_hash) {
|
||||||
|
throw new HTTPException(401, { message: 'Invalid username or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await Bun.password.verify(parsed.password, user.password_hash);
|
||||||
|
if (!valid) {
|
||||||
|
throw new HTTPException(401, { message: 'Invalid username or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await createToken({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /auth/me — return current user from token
|
||||||
|
router.get('/auth/me', requireAuth, async (c) => {
|
||||||
|
const authUser = c.get('user');
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, authUser.userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new HTTPException(404, { message: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /auth/tokens — create API token
|
||||||
|
router.post('/auth/tokens', requireAuth, async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const name = String(body.name || 'API token').trim();
|
||||||
|
const authUser = c.get('user');
|
||||||
|
|
||||||
|
const rawToken = `tessera_${crypto.randomUUID().replace(/-/g, '')}`;
|
||||||
|
const tokenHash = await Bun.password.hash(rawToken);
|
||||||
|
|
||||||
|
const [token] = await db.insert(apiTokens).values({
|
||||||
|
user_id: authUser.userId,
|
||||||
|
name,
|
||||||
|
token_hash: tokenHash,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new HTTPException(500, { message: 'Failed to create token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
id: token.id,
|
||||||
|
name: token.name,
|
||||||
|
token: rawToken,
|
||||||
|
created_at: token.created_at,
|
||||||
|
}, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /auth/tokens — list tokens
|
||||||
|
router.get('/auth/tokens', requireAuth, async (c) => {
|
||||||
|
const authUser = c.get('user');
|
||||||
|
const result = await db.query.apiTokens.findMany({
|
||||||
|
where: eq(apiTokens.user_id, authUser.userId),
|
||||||
|
orderBy: desc(apiTokens.created_at),
|
||||||
|
});
|
||||||
|
return c.json(result.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
last_used_at: t.last_used_at,
|
||||||
|
created_at: t.created_at,
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /auth/tokens/:id — revoke token
|
||||||
|
router.delete('/auth/tokens/:id', requireAuth, async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const authUser = c.get('user');
|
||||||
|
|
||||||
|
// Verify ownership before revoke
|
||||||
|
const allTokens = await db.query.apiTokens.findMany();
|
||||||
|
const existing = allTokens.find((t) => t.id === id && t.user_id === authUser.userId);
|
||||||
|
if (!existing) {
|
||||||
|
throw new HTTPException(404, { message: 'Token not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw delete to avoid Drizzle type issue with new apiTokens table
|
||||||
|
await db.execute(sql`DELETE FROM api_tokens WHERE id = ${id}`);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -276,6 +276,30 @@ export function createDashboardsRouter(db: Db): Hono {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Widget-level filters override or add to view filters
|
||||||
|
const widgetFilters = (widget.config as Record<string, unknown>)?.filters as Array<{ field: string; operator: string; value: string }> | undefined;
|
||||||
|
if (widgetFilters) {
|
||||||
|
for (const f of widgetFilters) {
|
||||||
|
if (f.field === 'status') {
|
||||||
|
if (f.operator === 'is_not') result = result.filter((t) => t.status !== f.value);
|
||||||
|
else result = result.filter((t) => t.status === f.value);
|
||||||
|
} else if (f.field === 'queue') {
|
||||||
|
if (f.operator === 'is_not') result = result.filter((t) => t.queue_id !== f.value);
|
||||||
|
else result = result.filter((t) => t.queue_id === f.value);
|
||||||
|
} else if (f.field === 'owner') {
|
||||||
|
if (f.value === 'unassigned') result = result.filter((t) => !t.owner_id);
|
||||||
|
else result = result.filter((t) => t.owner_id === f.value);
|
||||||
|
} else if (f.field === 'q') {
|
||||||
|
const q = f.value.toLowerCase();
|
||||||
|
result = result.filter((t) =>
|
||||||
|
t.subject.toLowerCase().includes(q) ||
|
||||||
|
String(t.id).includes(q) ||
|
||||||
|
(queueName.get(t.queue_id) ?? '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const limit = (widget.config as Record<string, unknown>)?.limit as number ?? 5;
|
const limit = (widget.config as Record<string, unknown>)?.limit as number ?? 5;
|
||||||
|
|
||||||
// Find lifecycle for status classification
|
// Find lifecycle for status classification
|
||||||
@@ -337,6 +361,61 @@ export function createDashboardsRouter(db: Db): Hono {
|
|||||||
return c.json({ type: 'status_chart', counts, total: result.length, title: widget.title, view_id: view.id });
|
return c.json({ type: 'status_chart', counts, total: result.length, title: widget.title, view_id: view.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'my_tickets': {
|
||||||
|
const authUser = c.get('user');
|
||||||
|
const myTickets = result.filter((t) => t.owner_id === authUser.userId);
|
||||||
|
return c.json({ type: 'my_tickets', total: myTickets.length, title: widget.title, view_id: view.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'trend_chart': {
|
||||||
|
const period = ((widget.config as Record<string, unknown>)?.period as string) ?? 'day';
|
||||||
|
const days = (widget.config as Record<string, unknown>)?.days as number ?? 30;
|
||||||
|
const trendField = ((widget.config as Record<string, unknown>)?.field as string) ?? 'created_at';
|
||||||
|
const now = new Date();
|
||||||
|
const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const filtered = result.filter((t) => {
|
||||||
|
const d = trendField === 'updated_at' ? t.updated_at : t.created_at;
|
||||||
|
return d && new Date(d) >= start;
|
||||||
|
});
|
||||||
|
|
||||||
|
const points: Record<string, number> = {};
|
||||||
|
for (const t of filtered) {
|
||||||
|
const d = new Date(trendField === 'updated_at' ? t.updated_at! : t.created_at!);
|
||||||
|
let key: string;
|
||||||
|
if (period === 'week') {
|
||||||
|
const weekStart = new Date(d);
|
||||||
|
weekStart.setDate(d.getDate() - d.getDay());
|
||||||
|
key = weekStart.toISOString().slice(0, 10);
|
||||||
|
} else {
|
||||||
|
key = d.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||||
|
}
|
||||||
|
points[key] = (points[key] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ type: 'trend_chart', counts: points, total: result.length, title: widget.title, view_id: view.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'overdue': {
|
||||||
|
const dateFieldKey = (widget.config as Record<string, unknown>)?.field_key as string;
|
||||||
|
const now = new Date();
|
||||||
|
const overdue = result.filter((t) => {
|
||||||
|
if (!dateFieldKey) {
|
||||||
|
// No specific field — check if any inactive-adjacent status
|
||||||
|
const lc = lifecycleByQueue.get(t.queue_id);
|
||||||
|
if (lc) {
|
||||||
|
const inactive = lc.statuses.inactive;
|
||||||
|
if (inactive.includes(t.status)) return false; // already resolved
|
||||||
|
}
|
||||||
|
// Check if updated_at is older than 7 days
|
||||||
|
const updated = t.updated_at ? new Date(t.updated_at) : new Date(0);
|
||||||
|
return (now.getTime() - updated.getTime()) > 7 * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
return false; // Would need CF value lookup for date field
|
||||||
|
});
|
||||||
|
return c.json({ type: 'overdue', total: overdue.length, title: widget.title, view_id: view.id });
|
||||||
|
}
|
||||||
|
|
||||||
case 'grouped_counts': {
|
case 'grouped_counts': {
|
||||||
const groupBy = (widget.config as Record<string, unknown>)?.group_by as string ?? 'owner';
|
const groupBy = (widget.config as Record<string, unknown>)?.group_by as string ?? 'owner';
|
||||||
const groups: Record<string, number> = {};
|
const groups: Record<string, number> = {};
|
||||||
|
|||||||
64
src/routes/notifications.ts
Normal file
64
src/routes/notifications.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { HTTPException } from 'hono/http-exception';
|
||||||
|
import type { Db } from '../db/index.ts';
|
||||||
|
import { notifications } from '../db/schema.ts';
|
||||||
|
import { and, eq, desc } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export function createNotificationsRouter(db: Db): Hono {
|
||||||
|
const router = new Hono();
|
||||||
|
|
||||||
|
// GET /notifications — list notifications for current user
|
||||||
|
router.get('/notifications', async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const result = await db.query.notifications.findMany({
|
||||||
|
where: eq(notifications.user_id, user.userId),
|
||||||
|
orderBy: desc(notifications.created_at),
|
||||||
|
// Return last 50
|
||||||
|
});
|
||||||
|
return c.json(result.slice(0, 50));
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /notifications/unread-count
|
||||||
|
router.get('/notifications/unread-count', async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const result = await db.query.notifications.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(notifications.user_id, user.userId),
|
||||||
|
eq(notifications.read, false),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return c.json({ count: result.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /notifications/:id/read — mark as read
|
||||||
|
router.patch('/notifications/:id/read', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
await db.update(notifications).set({ read: true }).where(eq(notifications.id, id));
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /notifications/read-all — mark all as read
|
||||||
|
router.patch('/notifications/read-all', async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
await db.update(notifications)
|
||||||
|
.set({ read: true })
|
||||||
|
.where(eq(notifications.user_id, user.userId));
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create notifications (used by other routes)
|
||||||
|
export async function createNotification(
|
||||||
|
db: Db,
|
||||||
|
data: { user_id: string; ticket_id?: number; type: string; title: string; body?: string },
|
||||||
|
) {
|
||||||
|
await db.insert(notifications).values({
|
||||||
|
user_id: data.user_id,
|
||||||
|
ticket_id: data.ticket_id ?? null,
|
||||||
|
type: data.type,
|
||||||
|
title: data.title,
|
||||||
|
body: data.body ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
176
src/routes/queue-permissions.ts
Normal file
176
src/routes/queue-permissions.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { HTTPException } from 'hono/http-exception';
|
||||||
|
import type { Db } from '../db/index.ts';
|
||||||
|
import { queuePermissions, userPermissions, teams, queues, users } from '../db/schema.ts';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export function createQueuePermissionsRouter(db: Db): Hono {
|
||||||
|
const router = new Hono();
|
||||||
|
|
||||||
|
// GET /queue-permissions — list all permissions (with team + queue names)
|
||||||
|
router.get('/queue-permissions', async (c) => {
|
||||||
|
const all = await db.query.queuePermissions.findMany();
|
||||||
|
|
||||||
|
// Enrich with names
|
||||||
|
const teamIds = [...new Set(all.map((p) => p.team_id))];
|
||||||
|
const queueIds = [...new Set(all.map((p) => p.queue_id))];
|
||||||
|
|
||||||
|
const teamList = teamIds.length > 0
|
||||||
|
? await db.query.teams.findMany({ where: (t, { inArray }) => inArray(t.id, teamIds) })
|
||||||
|
: [];
|
||||||
|
const queueList = queueIds.length > 0
|
||||||
|
? await db.query.queues.findMany({ where: (t, { inArray }) => inArray(t.id, queueIds) })
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const teamById = new Map(teamList.map((t) => [t.id, t]));
|
||||||
|
const queueById = new Map(queueList.map((q) => [q.id, q]));
|
||||||
|
|
||||||
|
const enriched = all.map((p) => ({
|
||||||
|
...p,
|
||||||
|
team_name: teamById.get(p.team_id)?.name ?? p.team_id,
|
||||||
|
queue_name: queueById.get(p.queue_id)?.name ?? p.queue_id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json(enriched);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /queue-permissions/teams-and-queues — return teams and queues for the form
|
||||||
|
router.get('/queue-permissions/teams-and-queues', async (c) => {
|
||||||
|
const [teamList, queueList] = await Promise.all([
|
||||||
|
db.query.teams.findMany(),
|
||||||
|
db.query.queues.findMany(),
|
||||||
|
]);
|
||||||
|
return c.json({ teams: teamList, queues: queueList });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /queue-permissions — grant a right
|
||||||
|
router.post('/queue-permissions', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { queue_id, team_id, right_name } = body;
|
||||||
|
|
||||||
|
if (!queue_id || !team_id || !right_name) {
|
||||||
|
throw new HTTPException(422, { message: 'queue_id, team_id, and right_name are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validRights = ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify', 'queue.admin'];
|
||||||
|
if (!validRights.includes(right_name)) {
|
||||||
|
throw new HTTPException(422, { message: `Invalid right. Must be one of: ${validRights.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate
|
||||||
|
const existing = await db.query.queuePermissions.findFirst({
|
||||||
|
where: (table, { and, eq: eqFn }) =>
|
||||||
|
and(
|
||||||
|
eqFn(table.queue_id, queue_id),
|
||||||
|
eqFn(table.team_id, team_id),
|
||||||
|
eqFn(table.right_name, right_name),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return c.json(existing); // Idempotent — return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
const [perm] = await db.insert(queuePermissions).values({
|
||||||
|
queue_id,
|
||||||
|
team_id,
|
||||||
|
right_name,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
if (!perm) {
|
||||||
|
throw new HTTPException(500, { message: 'Failed to create permission' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(perm, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /queue-permissions/:id — revoke a right
|
||||||
|
router.delete('/queue-permissions/:id', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const existing = await db.query.queuePermissions.findFirst({
|
||||||
|
where: eq(queuePermissions.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new HTTPException(404, { message: 'Permission not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(queuePermissions).where(eq(queuePermissions.id, id));
|
||||||
|
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /user-permissions — list all per-user permissions
|
||||||
|
router.get('/user-permissions', async (c) => {
|
||||||
|
const all = await db.query.userPermissions.findMany();
|
||||||
|
|
||||||
|
const userIds = [...new Set(all.map((p) => p.user_id))];
|
||||||
|
const queueIds = [...new Set(all.map((p) => p.queue_id))];
|
||||||
|
|
||||||
|
const userList = userIds.length > 0
|
||||||
|
? await db.query.users.findMany({ where: (t, { inArray }) => inArray(t.id, userIds) })
|
||||||
|
: [];
|
||||||
|
const queueList = queueIds.length > 0
|
||||||
|
? await db.query.queues.findMany({ where: (t, { inArray }) => inArray(t.id, queueIds) })
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const userById = new Map(userList.map((u) => [u.id, u]));
|
||||||
|
const queueById = new Map(queueList.map((q) => [q.id, q]));
|
||||||
|
|
||||||
|
const enriched = all.map((p) => ({
|
||||||
|
...p,
|
||||||
|
username: userById.get(p.user_id)?.username ?? p.user_id,
|
||||||
|
queue_name: queueById.get(p.queue_id)?.name ?? p.queue_id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json(enriched);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /user-permissions — grant a right to a user
|
||||||
|
router.post('/user-permissions', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { queue_id, user_id, right_name } = body;
|
||||||
|
|
||||||
|
if (!queue_id || !user_id || !right_name) {
|
||||||
|
throw new HTTPException(422, { message: 'queue_id, user_id, and right_name are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validRights = ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify', 'queue.admin'];
|
||||||
|
if (!validRights.includes(right_name)) {
|
||||||
|
throw new HTTPException(422, { message: `Invalid right. Must be one of: ${validRights.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.query.userPermissions.findFirst({
|
||||||
|
where: (table, { and, eq: eqFn }) =>
|
||||||
|
and(
|
||||||
|
eqFn(table.queue_id, queue_id),
|
||||||
|
eqFn(table.user_id, user_id),
|
||||||
|
eqFn(table.right_name, right_name),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) return c.json(existing);
|
||||||
|
|
||||||
|
const [perm] = await db.insert(userPermissions).values({
|
||||||
|
queue_id,
|
||||||
|
user_id,
|
||||||
|
right_name,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
if (!perm) {
|
||||||
|
throw new HTTPException(500, { message: 'Failed to create user permission' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(perm, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /user-permissions/:id — revoke a user right
|
||||||
|
router.delete('/user-permissions/:id', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
await db.delete(userPermissions).where(eq(userPermissions.id, id));
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { HTTPException } from 'hono/http-exception';
|
import { HTTPException } from 'hono/http-exception';
|
||||||
|
import { existsSync, mkdirSync } from 'node:fs';
|
||||||
|
import { join, extname } from 'node:path';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Db } from '../db/index.ts';
|
import type { Db } from '../db/index.ts';
|
||||||
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields, teamMembers } from '../db/schema.ts';
|
import { config } from '../config.ts';
|
||||||
|
import { getUserId } from '../auth/middleware.ts';
|
||||||
|
import { requireRight } from '../auth/permissions.ts';
|
||||||
|
import { createNotification } from './notifications.ts';
|
||||||
|
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields, teamMembers, transactionAttachments, ticketLinks } from '../db/schema.ts';
|
||||||
import { and, eq, asc, or, ilike, isNull, inArray, exists, sql } from 'drizzle-orm';
|
import { and, eq, asc, or, ilike, isNull, inArray, exists, sql } from 'drizzle-orm';
|
||||||
import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts';
|
import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts';
|
||||||
import { ScripEngine } from '../scrip/engine.ts';
|
import { ScripEngine } from '../scrip/engine.ts';
|
||||||
@@ -22,8 +30,14 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
|
|
||||||
// GET / — list tickets
|
// GET / — list tickets
|
||||||
router.get('/', async (c) => {
|
router.get('/', async (c) => {
|
||||||
const params = new URL(c.req.url).searchParams;
|
|
||||||
const queueId = c.req.query('queue_id');
|
const queueId = c.req.query('queue_id');
|
||||||
|
|
||||||
|
// If filtering by queue, check view permission
|
||||||
|
if (queueId) {
|
||||||
|
await requireRight(c, db, queueId, 'ticket.view');
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URL(c.req.url).searchParams;
|
||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
const ownerId = c.req.query('owner_id');
|
const ownerId = c.req.query('owner_id');
|
||||||
const teamId = c.req.query('team_id');
|
const teamId = c.req.query('team_id');
|
||||||
@@ -63,17 +77,70 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text search: push to SQL via ilike on ticket columns + queue name join
|
// Subject filter: supports "contains:<text>", "is:<text>", "is_not:<text>", "starts_with:<text>"
|
||||||
|
const subjectFilter = c.req.query('subject');
|
||||||
|
if (subjectFilter) {
|
||||||
|
const idx = subjectFilter.indexOf(':');
|
||||||
|
const op = idx > -1 ? subjectFilter.slice(0, idx) : 'contains';
|
||||||
|
const val = idx > -1 ? subjectFilter.slice(idx + 1) : subjectFilter;
|
||||||
|
if (val) {
|
||||||
|
if (op === 'is') conditions.push(eq(tickets.subject, val));
|
||||||
|
else if (op === 'is_not') conditions.push(sql`${tickets.subject} != ${val}`);
|
||||||
|
else if (op === 'starts_with') conditions.push(ilike(tickets.subject, `${val}%`));
|
||||||
|
else conditions.push(ilike(tickets.subject, `%${val}%`)); // contains
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date filters: format "before:YYYY-MM-DD" or "after:YYYY-MM-DD"
|
||||||
|
for (const [fieldName, column] of [['created', tickets.created_at], ['updated', tickets.updated_at]] as const) {
|
||||||
|
const dateFilter = c.req.query(fieldName);
|
||||||
|
if (dateFilter) {
|
||||||
|
const idx = dateFilter.indexOf(':');
|
||||||
|
const op = idx > -1 ? dateFilter.slice(0, idx) : 'after';
|
||||||
|
const val = idx > -1 ? dateFilter.slice(idx + 1) : dateFilter;
|
||||||
|
if (val) {
|
||||||
|
if (op === 'before') conditions.push(sql`${column} <= ${val}::timestamptz`);
|
||||||
|
else conditions.push(sql`${column} >= ${val}::timestamptz`); // after
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text search across tickets, transactions, queue names, and custom fields
|
||||||
if (query) {
|
if (query) {
|
||||||
const pattern = `%${query}%`;
|
const pattern = `%${query}%`;
|
||||||
conditions.push(
|
conditions.push(
|
||||||
or(
|
or(
|
||||||
ilike(tickets.subject, pattern),
|
ilike(tickets.subject, pattern),
|
||||||
ilike(tickets.status, pattern),
|
sql`${tickets.id}::text ILIKE ${pattern}`,
|
||||||
sql`${tickets.id}::text ILIKE ${pattern}`
|
// Queue name
|
||||||
|
exists(
|
||||||
|
db.select({ n: sql`1` })
|
||||||
|
.from(queues)
|
||||||
|
.where(and(
|
||||||
|
eq(queues.id, tickets.queue_id),
|
||||||
|
ilike(queues.name, pattern)
|
||||||
|
))
|
||||||
|
),
|
||||||
|
// Transaction bodies (comments, correspondence)
|
||||||
|
exists(
|
||||||
|
db.select({ n: sql`1` })
|
||||||
|
.from(transactions)
|
||||||
|
.where(and(
|
||||||
|
eq(transactions.ticket_id, tickets.id),
|
||||||
|
sql`transactions.data->>'body' ILIKE ${pattern}`
|
||||||
|
))
|
||||||
|
),
|
||||||
|
// Custom field values
|
||||||
|
exists(
|
||||||
|
db.select({ n: sql`1` })
|
||||||
|
.from(customFieldValues)
|
||||||
|
.where(and(
|
||||||
|
eq(customFieldValues.ticket_id, tickets.id),
|
||||||
|
ilike(customFieldValues.value, pattern)
|
||||||
|
))
|
||||||
|
)
|
||||||
)!
|
)!
|
||||||
);
|
);
|
||||||
// Queue name search requires join — keep as post-filter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom field filters: use EXISTS subquery
|
// Custom field filters: use EXISTS subquery
|
||||||
@@ -100,19 +167,9 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Post-filter for queue name text search (requires in-memory join)
|
|
||||||
let filtered = result;
|
|
||||||
if (query) {
|
|
||||||
const queuesForSearch = await db.query.queues.findMany();
|
|
||||||
const queueNameById = new Map(queuesForSearch.map((queue) => [queue.id, queue.name]));
|
|
||||||
filtered = result.filter((ticket) =>
|
|
||||||
(queueNameById.get(ticket.queue_id) ?? '').toLowerCase().includes(query.toLowerCase())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach custom field values to all tickets
|
// Attach custom field values to all tickets
|
||||||
if (filtered.length > 0) {
|
if (result.length > 0) {
|
||||||
const ticketIds = filtered.map((t) => t.id);
|
const ticketIds = result.map((t) => t.id);
|
||||||
const allCfValues = await db.query.customFieldValues.findMany({
|
const allCfValues = await db.query.customFieldValues.findMany({
|
||||||
where: (table, { inArray }) => inArray(table.ticket_id, ticketIds),
|
where: (table, { inArray }) => inArray(table.ticket_id, ticketIds),
|
||||||
});
|
});
|
||||||
@@ -124,7 +181,7 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
: [];
|
: [];
|
||||||
const fieldMap = new Map(allFields.map((f) => [f.id, f]));
|
const fieldMap = new Map(allFields.map((f) => [f.id, f]));
|
||||||
|
|
||||||
const ticketsWithCf = filtered.map((ticket) => {
|
const ticketsWithCf = result.map((ticket) => {
|
||||||
const cfs = allCfValues
|
const cfs = allCfValues
|
||||||
.filter((v) => v.ticket_id === ticket.id)
|
.filter((v) => v.ticket_id === ticket.id)
|
||||||
.map((v) => ({
|
.map((v) => ({
|
||||||
@@ -149,14 +206,16 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
return c.json(ticketsWithCf);
|
return c.json(ticketsWithCf);
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json(filtered);
|
return c.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST / — create ticket
|
// POST / — create ticket
|
||||||
router.post('/', async (c) => {
|
router.post('/', async (c) => {
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const parsed = CreateTicketSchema.parse(body);
|
const parsed = CreateTicketSchema.parse(body);
|
||||||
const creatorId = '00000000-0000-0000-0000-000000000000';
|
|
||||||
|
await requireRight(c, db, parsed.queue_id, 'ticket.create');
|
||||||
|
const creatorId = getUserId(c);
|
||||||
const customFieldInput = parsed.custom_fields ?? {};
|
const customFieldInput = parsed.custom_fields ?? {};
|
||||||
const customFieldEntries = Object.entries(customFieldInput)
|
const customFieldEntries = Object.entries(customFieldInput)
|
||||||
.map(([fieldId, value]) => [fieldId, value.trim()] as const)
|
.map(([fieldId, value]) => [fieldId, value.trim()] as const)
|
||||||
@@ -208,6 +267,21 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
throw new HTTPException(422, { message: `${field.name}: value is not an allowed option` });
|
throw new HTTPException(422, { message: `${field.name}: value is not an allowed option` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (field.field_type === 'date') {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||||
|
throw new HTTPException(422, { message: `${field.name}: value must be a date in YYYY-MM-DD format` });
|
||||||
|
}
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (isNaN(parsed.getTime())) {
|
||||||
|
throw new HTTPException(422, { message: `${field.name}: invalid date` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (field.field_type === 'datetime') {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (isNaN(parsed.getTime())) {
|
||||||
|
throw new HTTPException(422, { message: `${field.name}: value must be a valid ISO 8601 datetime` });
|
||||||
|
}
|
||||||
|
}
|
||||||
if (field.pattern) {
|
if (field.pattern) {
|
||||||
const regex = new RegExp(field.pattern);
|
const regex = new RegExp(field.pattern);
|
||||||
if (!regex.test(value)) {
|
if (!regex.test(value)) {
|
||||||
@@ -285,6 +359,8 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireRight(c, db, ticket.queue_id, 'ticket.view');
|
||||||
|
|
||||||
const cfValues = await db.query.customFieldValues.findMany({
|
const cfValues = await db.query.customFieldValues.findMany({
|
||||||
where: eq(customFieldValues.ticket_id, id),
|
where: eq(customFieldValues.ticket_id, id),
|
||||||
});
|
});
|
||||||
@@ -301,10 +377,32 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
custom_field: cfMap.get(v.custom_field_id) ?? null,
|
custom_field: cfMap.get(v.custom_field_id) ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return c.json({ ...ticket, custom_fields: customFieldsMapped });
|
// Blocking dependencies: tickets this one DependsOn that aren't resolved yet
|
||||||
|
const dependsOnLinks = await db.query.ticketLinks.findMany({
|
||||||
|
where: (t, { and, eq: eqFn }) =>
|
||||||
|
and(eqFn(t.ticket_id, id), eqFn(t.link_type, 'DependsOn')),
|
||||||
});
|
});
|
||||||
|
const blockingIds = dependsOnLinks.map((l) => l.target_ticket_id);
|
||||||
|
let blockedBy: Array<{ id: number; subject: string; status: string }> = [];
|
||||||
|
if (blockingIds.length > 0) {
|
||||||
|
const blockingTickets = await db.query.tickets.findMany({
|
||||||
|
where: (t, { inArray }) => inArray(t.id, blockingIds),
|
||||||
|
});
|
||||||
|
const queue = await db.query.queues.findFirst({
|
||||||
|
where: eq(queues.id, ticket.queue_id),
|
||||||
|
});
|
||||||
|
let inactiveStatuses: string[] = [];
|
||||||
|
if (queue?.lifecycle_id) {
|
||||||
|
const lc = await db.query.lifecycles.findFirst({ where: eq(lifecycles.id, queue.lifecycle_id) });
|
||||||
|
if (lc) inactiveStatuses = (lc.definition as any)?.statuses?.inactive ?? [];
|
||||||
|
}
|
||||||
|
blockedBy = blockingTickets
|
||||||
|
.filter((t) => !inactiveStatuses.includes(t.status))
|
||||||
|
.map((t) => ({ id: t.id, subject: t.subject, status: t.status }));
|
||||||
|
}
|
||||||
|
|
||||||
// PATCH /:id — update ticket
|
return c.json({ ...ticket, custom_fields: customFieldsMapped, blocked_by: blockedBy });
|
||||||
|
});
|
||||||
router.patch('/:id', async (c) => {
|
router.patch('/:id', async (c) => {
|
||||||
const id = Number(c.req.param('id'));
|
const id = Number(c.req.param('id'));
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
@@ -318,6 +416,8 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireRight(c, db, ticket.queue_id, 'ticket.modify');
|
||||||
|
|
||||||
let lifecycleDef: LifecycleDefinition | null = null;
|
let lifecycleDef: LifecycleDefinition | null = null;
|
||||||
|
|
||||||
// Validate lifecycle transition if status is changing
|
// Validate lifecycle transition if status is changing
|
||||||
@@ -337,6 +437,34 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
if (!result.valid) {
|
if (!result.valid) {
|
||||||
throw new HTTPException(422, { message: result.error ?? 'Invalid transition' });
|
throw new HTTPException(422, { message: result.error ?? 'Invalid transition' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check transition-gating right
|
||||||
|
if (result.requiredRight) {
|
||||||
|
await requireRight(c, db, ticket.queue_id, result.requiredRight as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check dependency enforcement: can't resolve/close if this ticket DependsOn unresolved tickets
|
||||||
|
const inactiveStatuses = lifecycleDef.statuses.inactive;
|
||||||
|
if (inactiveStatuses.includes(parsed.status)) {
|
||||||
|
const dependsOnLinks = await db.query.ticketLinks.findMany({
|
||||||
|
where: (t, { and, eq: eqFn }) =>
|
||||||
|
and(
|
||||||
|
eqFn(t.ticket_id, id),
|
||||||
|
eqFn(t.link_type, 'DependsOn'),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const link of dependsOnLinks) {
|
||||||
|
const target = await db.query.tickets.findFirst({
|
||||||
|
where: eq(tickets.id, link.target_ticket_id),
|
||||||
|
});
|
||||||
|
if (target && !inactiveStatuses.includes(target.status)) {
|
||||||
|
throw new HTTPException(422, {
|
||||||
|
message: `Cannot resolve: this ticket depends on ticket ${target.id} (${target.subject}) which is still ${target.status}. Resolve or close that ticket first.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -350,7 +478,7 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
field: 'subject',
|
field: 'subject',
|
||||||
old_value: ticket.subject,
|
old_value: ticket.subject,
|
||||||
new_value: parsed.subject,
|
new_value: parsed.subject,
|
||||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
creator_id: getUserId(c),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +489,7 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
field: 'status',
|
field: 'status',
|
||||||
old_value: ticket.status,
|
old_value: ticket.status,
|
||||||
new_value: parsed.status,
|
new_value: parsed.status,
|
||||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
creator_id: getUserId(c),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +500,7 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
field: 'owner_id',
|
field: 'owner_id',
|
||||||
old_value: ticket.owner_id ?? null,
|
old_value: ticket.owner_id ?? null,
|
||||||
new_value: parsed.owner_id,
|
new_value: parsed.owner_id,
|
||||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
creator_id: getUserId(c),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,7 +511,7 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
field: 'team_id',
|
field: 'team_id',
|
||||||
old_value: (ticket as any).team_id ?? null,
|
old_value: (ticket as any).team_id ?? null,
|
||||||
new_value: parsed.team_id,
|
new_value: parsed.team_id,
|
||||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
creator_id: getUserId(c),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,10 +553,24 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
await db.insert(transactions).values(txList as any);
|
await db.insert(transactions).values(txList as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run scrips
|
// Run scrips — use TransactionBatch when multiple changes, TransactionCreate for single
|
||||||
const prepared = await scripEngine.prepare(id, txList as any);
|
const stage = txList.length > 1 ? 'TransactionBatch' as const : 'TransactionCreate' as const;
|
||||||
|
const prepared = await scripEngine.prepare(id, txList as any, stage);
|
||||||
const results = await scripEngine.commit(prepared);
|
const results = await scripEngine.commit(prepared);
|
||||||
|
|
||||||
|
// Notify on assignment change
|
||||||
|
if (parsed.owner_id !== undefined && parsed.owner_id !== ticket.owner_id) {
|
||||||
|
if (parsed.owner_id) {
|
||||||
|
await createNotification(db, {
|
||||||
|
user_id: parsed.owner_id,
|
||||||
|
ticket_id: id,
|
||||||
|
type: 'assigned',
|
||||||
|
title: `You were assigned to ticket ${id}`,
|
||||||
|
body: ticket.subject,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({ ticket: updated, scrip_results: results });
|
return c.json({ ticket: updated, scrip_results: results });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -446,6 +588,8 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireRight(c, db, ticket.queue_id, 'ticket.modify');
|
||||||
|
|
||||||
if (parsed.status) {
|
if (parsed.status) {
|
||||||
const queue = await db.query.queues.findFirst({
|
const queue = await db.query.queues.findFirst({
|
||||||
where: eq(queues.id, ticket.queue_id),
|
where: eq(queues.id, ticket.queue_id),
|
||||||
@@ -470,13 +614,13 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
|
|
||||||
if (parsed.status && parsed.status !== ticket.status) {
|
if (parsed.status && parsed.status !== ticket.status) {
|
||||||
txList.push({
|
txList.push({
|
||||||
id: '00000000-0000-0000-0000-000000000000',
|
id: getUserId(c),
|
||||||
ticket_id: id,
|
ticket_id: id,
|
||||||
transaction_type: 'StatusChange',
|
transaction_type: 'StatusChange',
|
||||||
field: 'status',
|
field: 'status',
|
||||||
old_value: ticket.status,
|
old_value: ticket.status,
|
||||||
new_value: parsed.status,
|
new_value: parsed.status,
|
||||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
creator_id: getUserId(c),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,7 +631,7 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
return c.json({ prepared_scrips: results });
|
return c.json({ prepared_scrips: results });
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /:id/transactions — list transactions for ticket
|
// GET /:id/transactions — list transactions for ticket (with attachments)
|
||||||
router.get('/:id/transactions', async (c) => {
|
router.get('/:id/transactions', async (c) => {
|
||||||
const id = Number(c.req.param('id'));
|
const id = Number(c.req.param('id'));
|
||||||
|
|
||||||
@@ -496,7 +640,105 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
orderBy: asc(transactions.created_at),
|
orderBy: asc(transactions.created_at),
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json(result);
|
// Fetch attachments for these transactions
|
||||||
|
const txIds = result.map((tx) => tx.id);
|
||||||
|
if (txIds.length > 0) {
|
||||||
|
const attachments = await db.query.transactionAttachments.findMany({
|
||||||
|
where: inArray(transactionAttachments.transaction_id, txIds),
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachmentsByTxId = new Map<string, typeof attachments>();
|
||||||
|
for (const att of attachments) {
|
||||||
|
if (!att.transaction_id) continue;
|
||||||
|
const list = attachmentsByTxId.get(att.transaction_id);
|
||||||
|
if (list) {
|
||||||
|
list.push(att);
|
||||||
|
} else {
|
||||||
|
attachmentsByTxId.set(att.transaction_id, [att]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultWithAttachments = result.map((tx) => ({
|
||||||
|
...tx,
|
||||||
|
attachments: attachmentsByTxId.get(tx.id) ?? [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json(resultWithAttachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(result.map((tx) => ({ ...tx, attachments: [] })));
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /:id/attachments — upload file attachments for a ticket
|
||||||
|
router.post('/:id/attachments', async (c) => {
|
||||||
|
const ticketId = Number(c.req.param('id'));
|
||||||
|
|
||||||
|
const ticket = await db.query.tickets.findFirst({
|
||||||
|
where: eq(tickets.id, ticketId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireRight(c, db, ticket.queue_id, 'ticket.reply');
|
||||||
|
|
||||||
|
const formData = await c.req.formData();
|
||||||
|
const files = formData.getAll('files') as File[];
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new HTTPException(422, { message: 'No files provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear().toString();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dir = join(config.UPLOAD_DIR, year, month);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME_MAP: Record<string, string> = {
|
||||||
|
'.txt': 'text/plain', '.html': 'text/html', '.css': 'text/css',
|
||||||
|
'.js': 'application/javascript', '.json': 'application/json', '.xml': 'application/xml',
|
||||||
|
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml',
|
||||||
|
'.webp': 'image/webp', '.zip': 'application/zip', '.gz': 'application/gzip',
|
||||||
|
'.csv': 'text/csv', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg',
|
||||||
|
'.md': 'text/markdown', '.yaml': 'text/yaml', '.yml': 'text/yaml',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result: Array<{ id: string; filename: string; mime_type: string; size_bytes: number }> = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (!(file instanceof File)) continue;
|
||||||
|
|
||||||
|
const ext = extname(file.name).toLowerCase();
|
||||||
|
const storedName = `${randomUUID()}${ext}`;
|
||||||
|
const storagePath = join(dir, storedName);
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
await writeFile(storagePath, buffer);
|
||||||
|
|
||||||
|
const mimeType = file.type || MIME_MAP[ext] || 'application/octet-stream';
|
||||||
|
|
||||||
|
const [saved] = await db.insert(transactionAttachments).values({
|
||||||
|
filename: file.name,
|
||||||
|
mime_type: mimeType,
|
||||||
|
size_bytes: buffer.length,
|
||||||
|
storage_path: storagePath,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
if (saved) {
|
||||||
|
result.push({
|
||||||
|
id: saved.id,
|
||||||
|
filename: saved.filename,
|
||||||
|
mime_type: saved.mime_type,
|
||||||
|
size_bytes: saved.size_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ attachments: result }, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/comment — add a comment (reply or internal note)
|
// POST /:id/comment — add a comment (reply or internal note)
|
||||||
@@ -513,12 +755,23 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireRight(c, db, ticket.queue_id, parsed.internal ? 'ticket.comment' : 'ticket.reply');
|
||||||
|
|
||||||
const transactionType = parsed.internal ? 'Comment' : 'Correspond';
|
const transactionType = parsed.internal ? 'Comment' : 'Correspond';
|
||||||
|
const attachmentIds = parsed.attachment_ids ?? [];
|
||||||
|
|
||||||
|
const txData: Record<string, unknown> = { body: parsed.body };
|
||||||
|
if (attachmentIds.length > 0) {
|
||||||
|
txData.attachment_ids = attachmentIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeWorked = parsed.time_worked_minutes ?? 0;
|
||||||
|
|
||||||
const [tx] = await db.insert(transactions).values({
|
const [tx] = await db.insert(transactions).values({
|
||||||
ticket_id: id,
|
ticket_id: id,
|
||||||
transaction_type: transactionType,
|
transaction_type: transactionType,
|
||||||
data: { body: parsed.body },
|
data: txData,
|
||||||
|
time_worked_minutes: timeWorked,
|
||||||
creator_id: parsed.creator_id,
|
creator_id: parsed.creator_id,
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
@@ -526,14 +779,454 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
throw new HTTPException(500, { message: 'Failed to create comment' });
|
throw new HTTPException(500, { message: 'Failed to create comment' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Link pre-uploaded attachment records to this transaction
|
||||||
|
if (attachmentIds.length > 0) {
|
||||||
|
await db.update(transactionAttachments)
|
||||||
|
.set({ transaction_id: tx.id })
|
||||||
|
.where(inArray(transactionAttachments.id, attachmentIds));
|
||||||
|
}
|
||||||
|
|
||||||
// Run scrips
|
// Run scrips
|
||||||
const txList = [tx];
|
const txList = [tx];
|
||||||
const prepared = await scripEngine.prepare(id, txList as any);
|
const prepared = await scripEngine.prepare(id, txList as any);
|
||||||
await scripEngine.commit(prepared);
|
await scripEngine.commit(prepared);
|
||||||
|
|
||||||
|
// Notify ticket owner and creator
|
||||||
|
const commenterId = getUserId(c);
|
||||||
|
const notifyTargets = new Set([ticket.owner_id, ticket.creator_id].filter(Boolean) as string[]);
|
||||||
|
notifyTargets.delete(commenterId);
|
||||||
|
for (const userId of notifyTargets) {
|
||||||
|
await createNotification(db, {
|
||||||
|
user_id: userId,
|
||||||
|
ticket_id: id,
|
||||||
|
type: 'commented',
|
||||||
|
title: `New ${transactionType === 'Comment' ? 'internal note' : 'reply'} on ticket ${id}`,
|
||||||
|
body: parsed.body.slice(0, 200),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return c.json(tx, 201);
|
return c.json(tx, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /:id/links — list links for a ticket (with target ticket info)
|
||||||
|
router.get('/:id/links', async (c) => {
|
||||||
|
const id = Number(c.req.param('id'));
|
||||||
|
|
||||||
|
const ticket = await db.query.tickets.findFirst({
|
||||||
|
where: eq(tickets.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireRight(c, db, ticket.queue_id, 'ticket.view');
|
||||||
|
|
||||||
|
const links = await db.query.ticketLinks.findMany({
|
||||||
|
where: eq(ticketLinks.ticket_id, id),
|
||||||
|
orderBy: asc(ticketLinks.created_at),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enrich with target ticket info
|
||||||
|
const targetIds = [...new Set(links.map((l) => l.target_ticket_id))];
|
||||||
|
const targetTickets = targetIds.length > 0
|
||||||
|
? await db.query.tickets.findMany({
|
||||||
|
where: (table, { inArray }) => inArray(table.id, targetIds),
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const ticketById = new Map(targetTickets.map((t) => [t.id, t]));
|
||||||
|
|
||||||
|
const enriched = links.map((link) => {
|
||||||
|
const target = ticketById.get(link.target_ticket_id);
|
||||||
|
return {
|
||||||
|
...link,
|
||||||
|
target_ticket: target
|
||||||
|
? { id: target.id, subject: target.subject, status: target.status }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(enriched);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /:id/links — create a link to another ticket
|
||||||
|
router.post('/:id/links', async (c) => {
|
||||||
|
const id = Number(c.req.param('id'));
|
||||||
|
const body = await c.req.json();
|
||||||
|
const targetTicketId = Number(body.target_ticket_id);
|
||||||
|
const linkType = String(body.link_type || 'RelatedTo');
|
||||||
|
|
||||||
|
if (!targetTicketId || isNaN(targetTicketId)) {
|
||||||
|
throw new HTTPException(422, { message: 'target_ticket_id is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetTicketId === id) {
|
||||||
|
throw new HTTPException(422, { message: 'Cannot link a ticket to itself' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validTypes = ['DependsOn', 'Blocks', 'RefersTo', 'RelatedTo', 'Duplicates', 'MemberOf'];
|
||||||
|
if (!validTypes.includes(linkType)) {
|
||||||
|
throw new HTTPException(422, { message: `Invalid link_type. Must be one of: ${validTypes.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticket = await db.query.tickets.findFirst({
|
||||||
|
where: eq(tickets.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireRight(c, db, ticket.queue_id, 'ticket.modify');
|
||||||
|
|
||||||
|
const target = await db.query.tickets.findFirst({
|
||||||
|
where: eq(tickets.id, targetTicketId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
throw new HTTPException(404, { message: 'Target ticket not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate
|
||||||
|
const existing = await db.query.ticketLinks.findFirst({
|
||||||
|
where: (table, { and, eq: eqFn }) =>
|
||||||
|
and(
|
||||||
|
eqFn(table.ticket_id, id),
|
||||||
|
eqFn(table.target_ticket_id, targetTicketId),
|
||||||
|
eqFn(table.link_type, linkType),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new HTTPException(422, { message: 'This link already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const creatorId = body.creator_id || getUserId(c);
|
||||||
|
|
||||||
|
const [link] = await db.insert(ticketLinks).values({
|
||||||
|
ticket_id: id,
|
||||||
|
target_ticket_id: targetTicketId,
|
||||||
|
link_type: linkType,
|
||||||
|
creator_id: creatorId,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
throw new HTTPException(500, { message: 'Failed to create link' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create transactions on both tickets
|
||||||
|
const linkData = {
|
||||||
|
link_type: linkType,
|
||||||
|
link_id: link.id,
|
||||||
|
target_ticket_id: targetTicketId,
|
||||||
|
target_subject: target.subject,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reverseLinkData = {
|
||||||
|
link_type: linkType,
|
||||||
|
link_id: link.id,
|
||||||
|
target_ticket_id: id,
|
||||||
|
target_subject: ticket.subject,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [txSource] = await db.insert(transactions).values({
|
||||||
|
ticket_id: id,
|
||||||
|
transaction_type: 'LinkCreate',
|
||||||
|
field: linkType,
|
||||||
|
old_value: null,
|
||||||
|
new_value: String(targetTicketId),
|
||||||
|
data: linkData,
|
||||||
|
creator_id: creatorId,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
const [txTarget] = await db.insert(transactions).values({
|
||||||
|
ticket_id: targetTicketId,
|
||||||
|
transaction_type: 'LinkCreate',
|
||||||
|
field: linkType,
|
||||||
|
old_value: null,
|
||||||
|
new_value: String(id),
|
||||||
|
data: reverseLinkData,
|
||||||
|
creator_id: creatorId,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
// Run scrips on source ticket
|
||||||
|
if (txSource) {
|
||||||
|
const prepared = await scripEngine.prepare(id, [txSource] as any);
|
||||||
|
await scripEngine.commit(prepared);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run scrips on target ticket
|
||||||
|
if (txTarget) {
|
||||||
|
const prepared = await scripEngine.prepare(targetTicketId, [txTarget] as any);
|
||||||
|
await scripEngine.commit(prepared);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include target ticket info in response
|
||||||
|
return c.json({
|
||||||
|
...link,
|
||||||
|
target_ticket: { id: target.id, subject: target.subject, status: target.status },
|
||||||
|
}, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /:id/links/:linkId — remove a link
|
||||||
|
router.delete('/:id/links/:linkId', async (c) => {
|
||||||
|
const id = Number(c.req.param('id'));
|
||||||
|
const linkId = c.req.param('linkId');
|
||||||
|
|
||||||
|
const ticket = await db.query.tickets.findFirst({
|
||||||
|
where: eq(tickets.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireRight(c, db, ticket.queue_id, 'ticket.modify');
|
||||||
|
|
||||||
|
const link = await db.query.ticketLinks.findFirst({
|
||||||
|
where: eq(ticketLinks.id, linkId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!link || link.ticket_id !== id) {
|
||||||
|
throw new HTTPException(404, { message: 'Link not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(ticketLinks).where(eq(ticketLinks.id, linkId));
|
||||||
|
|
||||||
|
const target = await db.query.tickets.findFirst({
|
||||||
|
where: eq(tickets.id, link.target_ticket_id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const creatorId = getUserId(c);
|
||||||
|
|
||||||
|
const [txSource] = await db.insert(transactions).values({
|
||||||
|
ticket_id: id,
|
||||||
|
transaction_type: 'LinkDelete',
|
||||||
|
field: link.link_type,
|
||||||
|
old_value: String(link.target_ticket_id),
|
||||||
|
new_value: null,
|
||||||
|
data: {
|
||||||
|
link_type: link.link_type,
|
||||||
|
target_ticket_id: link.target_ticket_id,
|
||||||
|
target_subject: target?.subject ?? 'unknown',
|
||||||
|
},
|
||||||
|
creator_id: creatorId,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
// Run scrips on source ticket
|
||||||
|
if (txSource) {
|
||||||
|
const prepared = await scripEngine.prepare(id, [txSource] as any);
|
||||||
|
await scripEngine.commit(prepared);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /:id/merge — merge this ticket into another
|
||||||
|
router.post('/:id/merge', async (c) => {
|
||||||
|
const id = Number(c.req.param('id'));
|
||||||
|
const body = await c.req.json();
|
||||||
|
const targetId = Number(body.target_ticket_id);
|
||||||
|
|
||||||
|
if (!targetId || isNaN(targetId) || targetId === id) {
|
||||||
|
throw new HTTPException(422, { message: 'target_ticket_id must be a different ticket ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = await db.query.tickets.findFirst({ where: eq(tickets.id, id) });
|
||||||
|
if (!source) throw new HTTPException(404, { message: 'Source ticket not found' });
|
||||||
|
|
||||||
|
const target = await db.query.tickets.findFirst({ where: eq(tickets.id, targetId) });
|
||||||
|
if (!target) throw new HTTPException(404, { message: 'Target ticket not found' });
|
||||||
|
|
||||||
|
await requireRight(c, db, source.queue_id, 'ticket.modify');
|
||||||
|
await requireRight(c, db, target.queue_id, 'ticket.modify');
|
||||||
|
|
||||||
|
const creatorId = getUserId(c);
|
||||||
|
|
||||||
|
// Move transactions
|
||||||
|
await db.update(transactions)
|
||||||
|
.set({ ticket_id: targetId } as any)
|
||||||
|
.where(eq(transactions.ticket_id, id));
|
||||||
|
|
||||||
|
// Move attachments
|
||||||
|
const sourceTxs = await db.query.transactions.findMany({ where: eq(transactions.ticket_id, id) });
|
||||||
|
// (attachments are linked via transaction_id which stays the same, no-op)
|
||||||
|
|
||||||
|
// Move custom field values
|
||||||
|
const sourceCfs = await db.query.customFieldValues.findMany({
|
||||||
|
where: eq(customFieldValues.ticket_id, id),
|
||||||
|
});
|
||||||
|
for (const cf of sourceCfs) {
|
||||||
|
const existing = await db.query.customFieldValues.findFirst({
|
||||||
|
where: (t, { and, eq: eqFn }) =>
|
||||||
|
and(
|
||||||
|
eqFn(t.custom_field_id, cf.custom_field_id),
|
||||||
|
eqFn(t.ticket_id, targetId),
|
||||||
|
eqFn(t.value, cf.value),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
await db.delete(customFieldValues).where(eq(customFieldValues.id, cf.id));
|
||||||
|
} else {
|
||||||
|
await db.update(customFieldValues)
|
||||||
|
.set({ ticket_id: targetId } as any)
|
||||||
|
.where(eq(customFieldValues.id, cf.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move ticket links (update source references to target)
|
||||||
|
await db.update(ticketLinks)
|
||||||
|
.set({ ticket_id: targetId } as any)
|
||||||
|
.where(eq(ticketLinks.ticket_id, id));
|
||||||
|
// Update links pointing TO this ticket to point to target instead
|
||||||
|
await db.update(ticketLinks)
|
||||||
|
.set({ target_ticket_id: targetId } as any)
|
||||||
|
.where(eq(ticketLinks.target_ticket_id, id));
|
||||||
|
|
||||||
|
// Close the source ticket
|
||||||
|
await db.update(tickets).set({
|
||||||
|
status: 'closed',
|
||||||
|
updated_at: new Date(),
|
||||||
|
} as any).where(eq(tickets.id, id));
|
||||||
|
|
||||||
|
// Create merge transactions on both tickets
|
||||||
|
await db.insert(transactions).values({
|
||||||
|
ticket_id: targetId,
|
||||||
|
transaction_type: 'Comment',
|
||||||
|
data: { body: `Ticket ${source.id} (${source.subject}) was merged into this ticket.` },
|
||||||
|
creator_id: creatorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(transactions).values({
|
||||||
|
ticket_id: id,
|
||||||
|
transaction_type: 'StatusChange',
|
||||||
|
field: 'status',
|
||||||
|
old_value: source.status,
|
||||||
|
new_value: 'closed',
|
||||||
|
data: { merged_into: targetId, body: `Merged into ticket ${targetId} (${target.subject}).` },
|
||||||
|
creator_id: creatorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a duplicate link
|
||||||
|
await db.insert(ticketLinks).values({
|
||||||
|
ticket_id: id,
|
||||||
|
target_ticket_id: targetId,
|
||||||
|
link_type: 'Duplicates',
|
||||||
|
creator_id: creatorId,
|
||||||
|
}).onConflictDoNothing();
|
||||||
|
|
||||||
|
return c.json({ ok: true, target_id: targetId });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /batch — bulk update tickets
|
||||||
|
router.post('/batch', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const ticketIds: number[] = (body.ticket_ids ?? []).map(Number).filter((n: number) => !isNaN(n) && n > 0);
|
||||||
|
const { status, owner_id, team_id } = body;
|
||||||
|
|
||||||
|
if (ticketIds.length === 0) {
|
||||||
|
throw new HTTPException(422, { message: 'ticket_ids is required and must be an array of ticket IDs' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticketIds.length > 100) {
|
||||||
|
throw new HTTPException(422, { message: 'Maximum 100 tickets per batch update' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: Array<{ id: number; ok: boolean; error?: string }> = [];
|
||||||
|
|
||||||
|
for (const id of ticketIds) {
|
||||||
|
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, id) });
|
||||||
|
if (!ticket) {
|
||||||
|
results.push({ id, ok: false, error: 'Ticket not found' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireRight(c, db, ticket.queue_id, 'ticket.modify');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const txList: any[] = [];
|
||||||
|
const updateData: Record<string, unknown> = { updated_at: new Date() };
|
||||||
|
|
||||||
|
if (status !== undefined && status !== ticket.status) {
|
||||||
|
// Validate lifecycle transition
|
||||||
|
const queue = await db.query.queues.findFirst({ where: eq(queues.id, ticket.queue_id) });
|
||||||
|
if (queue?.lifecycle_id) {
|
||||||
|
const lifecycle = await db.query.lifecycles.findFirst({ where: eq(lifecycles.id, queue.lifecycle_id) });
|
||||||
|
if (lifecycle) {
|
||||||
|
const lifecycleDef = lifecycle.definition as LifecycleDefinition;
|
||||||
|
const result = lifecycleValidator.validateTransition(lifecycleDef, ticket.status, status);
|
||||||
|
if (!result.valid) {
|
||||||
|
results.push({ id, ok: false, error: result.error ?? 'Invalid transition' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Dependency enforcement
|
||||||
|
const inactiveStatuses = lifecycleDef.statuses.inactive;
|
||||||
|
if (inactiveStatuses.includes(status)) {
|
||||||
|
const dependsOnLinks = await db.query.ticketLinks.findMany({
|
||||||
|
where: (t, { and, eq: eqFn }) => and(eqFn(t.ticket_id, id), eqFn(t.link_type, 'DependsOn')),
|
||||||
|
});
|
||||||
|
let blocked = false;
|
||||||
|
for (const link of dependsOnLinks) {
|
||||||
|
const target = await db.query.tickets.findFirst({ where: eq(tickets.id, link.target_ticket_id) });
|
||||||
|
if (target && !inactiveStatuses.includes(target.status)) {
|
||||||
|
results.push({ id, ok: false, error: `Blocked by ticket ${target.id} (${target.subject}) — still ${target.status}` });
|
||||||
|
blocked = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (blocked) continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
txList.push({
|
||||||
|
ticket_id: id,
|
||||||
|
transaction_type: 'StatusChange',
|
||||||
|
field: 'status',
|
||||||
|
old_value: ticket.status,
|
||||||
|
new_value: status,
|
||||||
|
creator_id: getUserId(c),
|
||||||
|
});
|
||||||
|
updateData.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (owner_id !== undefined && owner_id !== ticket.owner_id) {
|
||||||
|
txList.push({
|
||||||
|
ticket_id: id,
|
||||||
|
transaction_type: 'SetOwner',
|
||||||
|
field: 'owner_id',
|
||||||
|
old_value: ticket.owner_id ?? null,
|
||||||
|
new_value: owner_id,
|
||||||
|
creator_id: getUserId(c),
|
||||||
|
});
|
||||||
|
updateData.owner_id = owner_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (team_id !== undefined && team_id !== (ticket as any).team_id) {
|
||||||
|
txList.push({
|
||||||
|
ticket_id: id,
|
||||||
|
transaction_type: 'SetTeam',
|
||||||
|
field: 'team_id',
|
||||||
|
old_value: (ticket as any).team_id ?? null,
|
||||||
|
new_value: team_id,
|
||||||
|
creator_id: getUserId(c),
|
||||||
|
});
|
||||||
|
updateData.team_id = team_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(tickets).set(updateData as any).where(eq(tickets.id, id));
|
||||||
|
if (txList.length > 0) {
|
||||||
|
await db.insert(transactions).values(txList as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({ id, ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
results.push({ id, ok: false, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ results });
|
||||||
|
});
|
||||||
|
|
||||||
// PATCH /:id/custom-fields/:fieldId — set or clear a custom field value
|
// PATCH /:id/custom-fields/:fieldId — set or clear a custom field value
|
||||||
router.patch('/:id/custom-fields/:fieldId', async (c) => {
|
router.patch('/:id/custom-fields/:fieldId', async (c) => {
|
||||||
const id = Number(c.req.param('id'));
|
const id = Number(c.req.param('id'));
|
||||||
@@ -549,6 +1242,8 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireRight(c, db, ticket.queue_id, 'ticket.modify');
|
||||||
|
|
||||||
const assignment = await db.query.queueCustomFields.findFirst({
|
const assignment = await db.query.queueCustomFields.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(queueCustomFields.queue_id, ticket.queue_id),
|
eq(queueCustomFields.queue_id, ticket.queue_id),
|
||||||
@@ -575,6 +1270,22 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (value && field.field_type === 'date') {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||||
|
throw new HTTPException(422, { message: `${field.name}: value must be a date in YYYY-MM-DD format` });
|
||||||
|
}
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (isNaN(parsed.getTime())) {
|
||||||
|
throw new HTTPException(422, { message: `${field.name}: invalid date` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value && field.field_type === 'datetime') {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (isNaN(parsed.getTime())) {
|
||||||
|
throw new HTTPException(422, { message: `${field.name}: value must be a valid ISO 8601 datetime` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (value && field.pattern) {
|
if (value && field.pattern) {
|
||||||
const regex = new RegExp(field.pattern);
|
const regex = new RegExp(field.pattern);
|
||||||
if (!regex.test(value)) {
|
if (!regex.test(value)) {
|
||||||
@@ -613,7 +1324,7 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
field: field.key,
|
field: field.key,
|
||||||
old_value: oldValue || null,
|
old_value: oldValue || null,
|
||||||
new_value: value || null,
|
new_value: value || null,
|
||||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
creator_id: getUserId(c),
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
const prepared = await scripEngine.prepare(id, [tx] as any);
|
const prepared = await scripEngine.prepare(id, [tx] as any);
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export function createUsersRouter(db: Db): Hono {
|
|||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const username = String(body.username ?? '').trim();
|
const username = String(body.username ?? '').trim();
|
||||||
const email = body.email ? String(body.email).trim() : null;
|
const email = body.email ? String(body.email).trim() : null;
|
||||||
|
const role = body.role ? String(body.role) : 'staff';
|
||||||
|
const password = body.password ? String(body.password).trim() : null;
|
||||||
|
|
||||||
if (!username) {
|
if (!username) {
|
||||||
throw new HTTPException(400, { message: 'username is required' });
|
throw new HTTPException(400, { message: 'username is required' });
|
||||||
@@ -26,6 +28,8 @@ export function createUsersRouter(db: Db): Hono {
|
|||||||
const [user] = await db.insert(users).values({
|
const [user] = await db.insert(users).values({
|
||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
|
role,
|
||||||
|
password_hash: password ? await Bun.password.hash(password) : null,
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -50,6 +54,10 @@ export function createUsersRouter(db: Db): Hono {
|
|||||||
const updateData: Partial<typeof users.$inferInsert> = {};
|
const updateData: Partial<typeof users.$inferInsert> = {};
|
||||||
if (body.username !== undefined) updateData.username = String(body.username).trim();
|
if (body.username !== undefined) updateData.username = String(body.username).trim();
|
||||||
if (body.email !== undefined) updateData.email = body.email ? String(body.email).trim() : null;
|
if (body.email !== undefined) updateData.email = body.email ? String(body.email).trim() : null;
|
||||||
|
if (body.role !== undefined) updateData.role = String(body.role);
|
||||||
|
if (body.password !== undefined && String(body.password).trim()) {
|
||||||
|
updateData.password_hash = await Bun.password.hash(String(body.password).trim());
|
||||||
|
}
|
||||||
|
|
||||||
const [updated] = await db.update(users)
|
const [updated] = await db.update(users)
|
||||||
.set(updateData)
|
.set(updateData)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { LifecycleDefinition } from '../lifecycle/validator.ts';
|
|||||||
|
|
||||||
export interface ConditionEvaluateContext {
|
export interface ConditionEvaluateContext {
|
||||||
lifecycleDef?: LifecycleDefinition;
|
lifecycleDef?: LifecycleDefinition;
|
||||||
|
customFields?: Record<string, string>; // key → value map of CF values
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConditionConfig {
|
export interface ConditionConfig {
|
||||||
@@ -16,6 +17,7 @@ export interface ConditionConfig {
|
|||||||
old_value?: unknown;
|
old_value?: unknown;
|
||||||
new_value?: unknown;
|
new_value?: unknown;
|
||||||
value?: unknown;
|
value?: unknown;
|
||||||
|
link_type?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConditionEvaluator {
|
export interface ConditionEvaluator {
|
||||||
@@ -82,11 +84,52 @@ export class OnCustomFieldChange implements ConditionEvaluator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class OnLinkCreate implements ConditionEvaluator {
|
||||||
|
evaluate(_ticket: Ticket, transactions: Transaction[], _context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
|
||||||
|
return transactions.some((tx) => {
|
||||||
|
if (tx.transaction_type !== 'LinkCreate') return false;
|
||||||
|
if (config?.link_type) {
|
||||||
|
const linkType = tx.field;
|
||||||
|
if (!matchesStatusFilter(linkType, config.link_type)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OnOverdue implements ConditionEvaluator {
|
||||||
|
evaluate(_ticket: Ticket, _transactions: Transaction[], context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
|
||||||
|
const fieldKey = config?.field_key ?? config?.field_id ?? config?.field;
|
||||||
|
if (!fieldKey) return false;
|
||||||
|
|
||||||
|
const cfValue = context?.customFields?.[String(fieldKey)];
|
||||||
|
if (!cfValue) return false;
|
||||||
|
|
||||||
|
// Parse the date value
|
||||||
|
const dueDate = new Date(cfValue);
|
||||||
|
if (isNaN(dueDate.getTime())) return false;
|
||||||
|
|
||||||
|
// Check if overdue (past due date)
|
||||||
|
if (new Date() <= dueDate) return false;
|
||||||
|
|
||||||
|
// Check that ticket is still active (not in inactive state)
|
||||||
|
const lifecycleDef = context?.lifecycleDef;
|
||||||
|
if (lifecycleDef) {
|
||||||
|
const inactiveStates = lifecycleDef.statuses.inactive;
|
||||||
|
if (inactiveStates.includes(_ticket.status)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const conditionRegistry: Record<string, ConditionEvaluator> = {
|
const conditionRegistry: Record<string, ConditionEvaluator> = {
|
||||||
OnCreate: new OnCreate(),
|
OnCreate: new OnCreate(),
|
||||||
OnStatusChange: new OnStatusChange(),
|
OnStatusChange: new OnStatusChange(),
|
||||||
OnResolve: new OnResolve(),
|
OnResolve: new OnResolve(),
|
||||||
OnCustomFieldChange: new OnCustomFieldChange(),
|
OnCustomFieldChange: new OnCustomFieldChange(),
|
||||||
|
OnLinkCreate: new OnLinkCreate(),
|
||||||
|
OnOverdue: new OnOverdue(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getConditionEvaluator(type: string): ConditionEvaluator | null {
|
export function getConditionEvaluator(type: string): ConditionEvaluator | null {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export class ScripEngine {
|
|||||||
async prepare(
|
async prepare(
|
||||||
ticketId: number,
|
ticketId: number,
|
||||||
transactions: Transaction[],
|
transactions: Transaction[],
|
||||||
|
stage: 'TransactionCreate' | 'TransactionBatch' = 'TransactionCreate',
|
||||||
): Promise<PreparedScrip[]> {
|
): Promise<PreparedScrip[]> {
|
||||||
const ticketRecord = await this.db.query.tickets.findFirst({
|
const ticketRecord = await this.db.query.tickets.findFirst({
|
||||||
where: eq(tickets.id, ticketId),
|
where: eq(tickets.id, ticketId),
|
||||||
@@ -53,6 +54,15 @@ export class ScripEngine {
|
|||||||
const matchingScrips = allScrips.filter((scrip) => {
|
const matchingScrips = allScrips.filter((scrip) => {
|
||||||
if (scrip.disabled) return false;
|
if (scrip.disabled) return false;
|
||||||
if (scrip.queue_id !== null && scrip.queue_id !== ticketRecord.queue_id) return false;
|
if (scrip.queue_id !== null && scrip.queue_id !== ticketRecord.queue_id) return false;
|
||||||
|
if (scrip.stage !== stage) return false;
|
||||||
|
// Filter by applicable transaction types — if set, at least one tx must match
|
||||||
|
if (scrip.applicable_trans_types) {
|
||||||
|
const types = scrip.applicable_trans_types.split(',').map((t) => t.trim()).filter(Boolean);
|
||||||
|
if (types.length > 0 && !types.includes('Any')) {
|
||||||
|
const txTypes = new Set(transactions.map((tx) => tx.transaction_type));
|
||||||
|
if (!types.some((t) => txTypes.has(t))) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,10 +80,6 @@ export class ScripEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const conditionContext: ConditionEvaluateContext = {
|
|
||||||
lifecycleDef,
|
|
||||||
};
|
|
||||||
|
|
||||||
const cfValues = await this.db.query.customFieldValues.findMany({
|
const cfValues = await this.db.query.customFieldValues.findMany({
|
||||||
where: eq(customFieldValues.ticket_id, ticketId),
|
where: eq(customFieldValues.ticket_id, ticketId),
|
||||||
});
|
});
|
||||||
@@ -94,6 +100,11 @@ export class ScripEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const conditionContext: ConditionEvaluateContext = {
|
||||||
|
lifecycleDef,
|
||||||
|
customFields: customFieldsMap,
|
||||||
|
};
|
||||||
|
|
||||||
const prepared: PreparedScrip[] = [];
|
const prepared: PreparedScrip[] = [];
|
||||||
|
|
||||||
for (const scrip of matchingScrips) {
|
for (const scrip of matchingScrips) {
|
||||||
|
|||||||
92
src/scrip/scheduler.ts
Normal file
92
src/scrip/scheduler.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import type { Db } from '../db/index.ts';
|
||||||
|
import { transactions, tickets, queues, lifecycles } from '../db/schema.ts';
|
||||||
|
import { eq, and, isNull } from 'drizzle-orm';
|
||||||
|
import { ScripEngine } from './engine.ts';
|
||||||
|
|
||||||
|
const SYSTEM_USER = '00000000-0000-0000-0000-000000000000';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run scheduled scrips against all active tickets.
|
||||||
|
* Creates a synthetic "Scheduled" transaction so conditions like OnOverdue can fire.
|
||||||
|
*/
|
||||||
|
export async function runScheduledScrips(db: Db): Promise<{ checked: number; fired: number }> {
|
||||||
|
const engine = new ScripEngine(db);
|
||||||
|
|
||||||
|
// Get all lifecycles to determine inactive statuses
|
||||||
|
const allLifecycles = await db.query.lifecycles.findMany();
|
||||||
|
const inactiveByQueue = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
// Get all queues with lifecycles
|
||||||
|
const allQueues = await db.query.queues.findMany();
|
||||||
|
for (const q of allQueues) {
|
||||||
|
if (q.lifecycle_id) {
|
||||||
|
const lc = allLifecycles.find((l) => l.id === q.lifecycle_id);
|
||||||
|
if (lc) {
|
||||||
|
const def = lc.definition as any;
|
||||||
|
inactiveByQueue.set(q.id, new Set(def?.statuses?.inactive ?? ['resolved', 'closed']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all potentially active tickets
|
||||||
|
const allTickets = await db.query.tickets.findMany();
|
||||||
|
const active = allTickets.filter((t) => {
|
||||||
|
const inactive = inactiveByQueue.get(t.queue_id);
|
||||||
|
if (inactive) return !inactive.has(t.status);
|
||||||
|
return !['resolved', 'closed'].includes(t.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
let fired = 0;
|
||||||
|
|
||||||
|
for (const ticket of active) {
|
||||||
|
try {
|
||||||
|
// Create a synthetic Scheduled transaction
|
||||||
|
const [tx] = await db.insert(transactions).values({
|
||||||
|
ticket_id: ticket.id,
|
||||||
|
transaction_type: 'Comment' as any,
|
||||||
|
field: 'scheduled',
|
||||||
|
data: { body: 'Scheduled scrip evaluation' },
|
||||||
|
creator_id: SYSTEM_USER,
|
||||||
|
} as any).returning();
|
||||||
|
|
||||||
|
if (!tx) continue;
|
||||||
|
|
||||||
|
// Run scrips
|
||||||
|
const prepared = await engine.prepare(ticket.id, [tx as any]);
|
||||||
|
if (prepared.length > 0) {
|
||||||
|
const results = await engine.commit(prepared);
|
||||||
|
const successes = results.filter((r) => r.success);
|
||||||
|
if (successes.length > 0) fired += successes.length;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Log and continue — don't let one failing ticket block the scheduler
|
||||||
|
console.error(`[scheduler] Error processing ticket ${ticket.id}:`, err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { checked: active.length, fired };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the background scheduler. Runs every `intervalMinutes` minutes.
|
||||||
|
*/
|
||||||
|
export function startScheduler(db: Db, intervalMinutes = 5) {
|
||||||
|
console.log(`[scheduler] Starting scrip scheduler (every ${intervalMinutes}m)`);
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
const result = await runScheduledScrips(db);
|
||||||
|
if (result.fired > 0) {
|
||||||
|
console.log(`[scheduler] Checked ${result.checked} tickets, fired ${result.fired} scrip actions`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[scheduler] Error:', err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run once at startup after a short delay
|
||||||
|
setTimeout(run, 10000);
|
||||||
|
|
||||||
|
// Then run on interval
|
||||||
|
setInterval(run, intervalMinutes * 60 * 1000);
|
||||||
|
}
|
||||||
@@ -73,6 +73,7 @@ import {
|
|||||||
removeTeamMember,
|
removeTeamMember,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import type { Queue, Ticket, Lifecycle, LifecycleDefinition, Scrip, Template, CustomField, QueueCustomField, TemplatePreview, User, Team } from "@/lib/types";
|
import type { Queue, Ticket, Lifecycle, LifecycleDefinition, Scrip, Template, CustomField, QueueCustomField, TemplatePreview, User, Team } from "@/lib/types";
|
||||||
|
import { ScripWizard } from "@/components/scrip-wizard";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function AdminHeader() {
|
function AdminHeader() {
|
||||||
@@ -802,6 +803,7 @@ return { message: "Metadata fetched" };`);
|
|||||||
const [disabled, setDisabled] = useState(false);
|
const [disabled, setDisabled] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
|
|
||||||
const fetchScrips = useCallback(async () => {
|
const fetchScrips = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -1189,10 +1191,15 @@ return { message: "Metadata fetched" };`);
|
|||||||
Build automations visually, then fine-tune the exact action payload in JSON.
|
Build automations visually, then fine-tune the exact action payload in JSON.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Button size="sm" onClick={resetBuilder} className="h-8 bg-primary">
|
<Button size="sm" onClick={resetBuilder} className="h-8 bg-primary">
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
New scrip
|
New scrip
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setWizardOpen(true)} className="h-8">
|
||||||
|
Guided setup
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ErrorBanner error={error} />
|
<ErrorBanner error={error} />
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -1701,6 +1708,24 @@ return { message: "Metadata fetched" };`);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ScripWizard
|
||||||
|
open={wizardOpen}
|
||||||
|
onClose={() => setWizardOpen(false)}
|
||||||
|
error={saveError}
|
||||||
|
onCreate={async (data) => {
|
||||||
|
// Strip nulls for Zod optional fields
|
||||||
|
const payload = Object.fromEntries(
|
||||||
|
Object.entries(data).filter(([, v]) => v !== null)
|
||||||
|
);
|
||||||
|
const { error: createErr } = await createScrip(payload as any);
|
||||||
|
if (createErr) { setSaveError(createErr); return; }
|
||||||
|
setWizardOpen(false);
|
||||||
|
await fetchScrips();
|
||||||
|
}}
|
||||||
|
queues={queues}
|
||||||
|
customFields={customFields}
|
||||||
|
templates={templates}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { CountWidget } from "@/components/widgets/count-widget";
|
|||||||
import { TicketListWidget } from "@/components/widgets/ticket-list-widget";
|
import { TicketListWidget } from "@/components/widgets/ticket-list-widget";
|
||||||
import { StatusChartWidget } from "@/components/widgets/status-chart-widget";
|
import { StatusChartWidget } from "@/components/widgets/status-chart-widget";
|
||||||
import { GroupedCountsWidget } from "@/components/widgets/grouped-counts-widget";
|
import { GroupedCountsWidget } from "@/components/widgets/grouped-counts-widget";
|
||||||
|
import { TrendChartWidget } from "@/components/widgets/trend-chart-widget";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function widgetGridStyle(position: { x: number; y: number; w: number; h: number }) {
|
function widgetGridStyle(position: { x: number; y: number; w: number; h: number }) {
|
||||||
@@ -331,13 +332,18 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
|
|||||||
|
|
||||||
switch (widget.data.type) {
|
switch (widget.data.type) {
|
||||||
case "count":
|
case "count":
|
||||||
|
case "my_tickets":
|
||||||
return <CountWidget data={widget.data} />;
|
return <CountWidget data={widget.data} />;
|
||||||
|
case "overdue":
|
||||||
|
return <CountWidget data={{ ...widget.data, type: "count" }} />;
|
||||||
case "ticket_list":
|
case "ticket_list":
|
||||||
return <TicketListWidget data={widget.data} />;
|
return <TicketListWidget data={widget.data} />;
|
||||||
case "status_chart":
|
case "status_chart":
|
||||||
return <StatusChartWidget data={widget.data} />;
|
return <StatusChartWidget data={widget.data} />;
|
||||||
case "grouped_counts":
|
case "grouped_counts":
|
||||||
return <GroupedCountsWidget data={widget.data} />;
|
return <GroupedCountsWidget data={widget.data} />;
|
||||||
|
case "trend_chart":
|
||||||
|
return <TrendChartWidget data={widget.data} />;
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
|
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
|
||||||
@@ -554,6 +560,9 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
|
|||||||
<option value="ticket_list">Ticket list (mini table)</option>
|
<option value="ticket_list">Ticket list (mini table)</option>
|
||||||
<option value="status_chart">Status chart (donut)</option>
|
<option value="status_chart">Status chart (donut)</option>
|
||||||
<option value="grouped_counts">Grouped counts (bar chart)</option>
|
<option value="grouped_counts">Grouped counts (bar chart)</option>
|
||||||
|
<option value="my_tickets">My tickets (auto-scoped)</option>
|
||||||
|
<option value="overdue">Overdue / stale</option>
|
||||||
|
<option value="trend_chart">Trend chart (bar)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{addType === "grouped_counts" && (
|
{addType === "grouped_counts" && (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Suspense } from "react";
|
|||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { AuthProvider } from "@/lib/auth-context";
|
||||||
|
|
||||||
const ibmPlexSans = IBM_Plex_Sans({
|
const ibmPlexSans = IBM_Plex_Sans({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -31,9 +32,11 @@ export default function RootLayout({
|
|||||||
style={{ fontSize: "15px", lineHeight: 1.5 }}
|
style={{ fontSize: "15px", lineHeight: 1.5 }}
|
||||||
>
|
>
|
||||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||||
|
<AuthProvider>
|
||||||
<Suspense fallback={<div className="flex items-center justify-center h-screen text-muted-foreground">Loading...</div>}>
|
<Suspense fallback={<div className="flex items-center justify-center h-screen text-muted-foreground">Loading...</div>}>
|
||||||
<AppShell>{children}</AppShell>
|
<AppShell>{children}</AppShell>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
97
web/src/app/login/page.tsx
Normal file
97
web/src/app/login/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
import { LogInIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { login, user } = useAuth();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Already logged in
|
||||||
|
if (user) {
|
||||||
|
router.replace("/");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!username.trim() || !password) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const result = await login(username.trim(), password);
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
setError(result);
|
||||||
|
} else {
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background/80">
|
||||||
|
<div className="w-full max-w-sm space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Tessera</h1>
|
||||||
|
<p className="mt-1.5 text-sm text-muted-foreground">Sign in to your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor="username" className="text-[10px] font-medium text-muted-foreground">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus:border-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor="password" className="text-[10px] font-medium text-muted-foreground">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus:border-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !username.trim() || !password}
|
||||||
|
className="flex h-9 w-full items-center justify-center gap-2 rounded-md bg-primary text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
<LogInIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-[10px] text-muted-foreground/60">
|
||||||
|
Demo: admin / admin
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ArrowDownAZIcon,
|
ArrowDownAZIcon,
|
||||||
CheckCircle2Icon,
|
CheckCircle2Icon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
|
DownloadIcon,
|
||||||
GaugeIcon,
|
GaugeIcon,
|
||||||
LayoutGridIcon,
|
LayoutGridIcon,
|
||||||
LayoutListIcon,
|
LayoutListIcon,
|
||||||
@@ -18,8 +19,8 @@ import {
|
|||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers, getViews, createView, deleteView, getDashboards, updateTicket } from "@/lib/api";
|
import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers, getViews, createView, deleteView, getDashboards, updateTicket, batchUpdateTickets, getTeams } from "@/lib/api";
|
||||||
import type { CustomField, Lifecycle, Queue, QueueCustomField, SavedView, Ticket, User } from "@/lib/types";
|
import type { CustomField, Lifecycle, Queue, QueueCustomField, SavedView, Team, Ticket, User } from "@/lib/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -30,6 +31,8 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { cn, formatTicketId } from "@/lib/utils";
|
import { cn, formatTicketId } from "@/lib/utils";
|
||||||
|
import { SearchableSelect } from "@/components/searchable-select";
|
||||||
|
import { LayoutBuilder, type SubtitleEntry } from "@/components/layout-builder";
|
||||||
|
|
||||||
const STATUS_META: Record<string, { label: string; color: string; tone: string }> = {
|
const STATUS_META: Record<string, { label: string; color: string; tone: string }> = {
|
||||||
new: { label: "New", color: "#64748b", tone: "bg-slate-500/10 text-slate-700 dark:text-slate-300" },
|
new: { label: "New", color: "#64748b", tone: "bg-slate-500/10 text-slate-700 dark:text-slate-300" },
|
||||||
@@ -51,35 +54,22 @@ type SortKey = "updated" | "created" | "id";
|
|||||||
interface ColumnConfig {
|
interface ColumnConfig {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
width: number; // px
|
width: number;
|
||||||
visible: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_COLUMNS: ColumnConfig[] = [
|
const ALL_FIELDS: ColumnConfig[] = [
|
||||||
{ key: "id", label: "ID", width: 100, visible: true },
|
{ key: "id", label: "ID", width: 100 },
|
||||||
{ key: "subject", label: "Subject", width: 320, visible: true },
|
{ key: "subject", label: "Subject", width: 400 },
|
||||||
{ key: "status", label: "Status", width: 120, visible: true },
|
{ key: "status", label: "Status", width: 120 },
|
||||||
{ key: "queue", label: "Queue", width: 140, visible: true },
|
{ key: "queue", label: "Queue", width: 140 },
|
||||||
{ key: "owner", label: "Owner", width: 130, visible: true },
|
{ key: "owner", label: "Owner", width: 130 },
|
||||||
{ key: "created", label: "Created", width: 130, visible: false },
|
{ key: "created", label: "Created", width: 130 },
|
||||||
{ key: "updated", label: "Updated", width: 130, visible: false },
|
{ key: "updated", label: "Updated", width: 130 },
|
||||||
|
{ key: "team", label: "Team", width: 130 },
|
||||||
];
|
];
|
||||||
|
|
||||||
function baseColumns(): ColumnConfig[] {
|
const DEFAULT_ROW1 = ["id", "subject", "status"];
|
||||||
return [
|
const DEFAULT_ROW2 = ["queue", "owner"];
|
||||||
{ key: "id", label: "ID", width: 100, visible: true },
|
|
||||||
{ key: "subject", label: "Subject", width: 400, visible: true },
|
|
||||||
{ key: "status", label: "Status", width: 120, visible: true },
|
|
||||||
{ key: "queue", label: "Queue", width: 140, visible: true },
|
|
||||||
{ key: "owner", label: "Owner", width: 130, visible: true },
|
|
||||||
{ key: "created", label: "Created", width: 130, visible: false },
|
|
||||||
{ key: "updated", label: "Updated", width: 130, visible: false },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultColumns(): ColumnConfig[] {
|
|
||||||
return baseColumns().map((c) => ({ ...c }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const LS_KEY = "tessera_columns";
|
const LS_KEY = "tessera_columns";
|
||||||
|
|
||||||
@@ -105,6 +95,22 @@ function queueName(queues: Queue[], queueId: string) {
|
|||||||
return queues.find((queue) => queue.id === queueId)?.name ?? queueId.slice(0, 8);
|
return queues.find((queue) => queue.id === queueId)?.name ?? queueId.slice(0, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSubtitleValue(key: string, ticket: Ticket, context: { users: User[]; queues: Queue[]; teamsList: Team[] }): string | null {
|
||||||
|
if (key === "subject") return null;
|
||||||
|
if (key === "id") return formatTicketId(ticket.id);
|
||||||
|
if (key === "status") return statusLabel(ticket.status);
|
||||||
|
if (key === "queue") return context.queues.find((q) => q.id === ticket.queue_id)?.name ?? ticket.queue_id.slice(0, 8);
|
||||||
|
if (key === "owner") return ticket.owner_id ? (context.users.find((u) => u.id === ticket.owner_id)?.username ?? "assigned") : "Unassigned";
|
||||||
|
if (key === "team") return context.teamsList.find((t) => t.id === ticket.team_id)?.name ?? null;
|
||||||
|
if (key === "created") return relativeTime(ticket.created_at);
|
||||||
|
if (key === "updated") return relativeTime(ticket.updated_at);
|
||||||
|
if (key.startsWith("cf.")) {
|
||||||
|
const cfKey = key.slice(3);
|
||||||
|
return ticket.custom_fields?.find((v) => v.custom_field?.key === cfKey || v.custom_field?.name === cfKey)?.value ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function relativeTime(value: string) {
|
function relativeTime(value: string) {
|
||||||
return formatDistanceToNow(new Date(value), { addSuffix: true });
|
return formatDistanceToNow(new Date(value), { addSuffix: true });
|
||||||
}
|
}
|
||||||
@@ -178,8 +184,10 @@ function TicketWorkbenchContent() {
|
|||||||
const [queues, setQueues] = useState<Queue[]>([]);
|
const [queues, setQueues] = useState<Queue[]>([]);
|
||||||
const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]);
|
const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]);
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [teamsList, setTeamsList] = useState<Team[]>([]);
|
||||||
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
||||||
const [clock, setClock] = useState(0);
|
const [clock, setClock] = useState(0);
|
||||||
|
const [initialLoad, setInitialLoad] = useState(true);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -187,47 +195,82 @@ function TicketWorkbenchContent() {
|
|||||||
const [batchSaving, setBatchSaving] = useState(false);
|
const [batchSaving, setBatchSaving] = useState(false);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||||
|
const searchRef = useRef(searchQuery);
|
||||||
|
searchRef.current = searchQuery;
|
||||||
|
// Debounce search: update debouncedQuery 300ms after user stops typing
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebouncedQuery(searchQuery), 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery]);
|
||||||
const [filters, setFilters] = useState<Filter[]>([]);
|
const [filters, setFilters] = useState<Filter[]>([]);
|
||||||
const [columns, setColumns] = useState<ColumnConfig[]>(() => {
|
const [row1Keys, setRow1Keys] = useState<string[]>(() => {
|
||||||
if (typeof window === "undefined") return defaultColumns();
|
if (typeof window === "undefined") return DEFAULT_ROW1;
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(LS_KEY);
|
const stored = localStorage.getItem(LS_KEY);
|
||||||
if (stored) return JSON.parse(stored) as ColumnConfig[];
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
if (parsed.row1) return parsed.row1 as string[];
|
||||||
|
// Migrate old format
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.filter((c: any) => c.visible !== false && c.display !== "subtitle" && c.display !== "hidden").map((c: any) => c.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return defaultColumns();
|
return DEFAULT_ROW1;
|
||||||
|
});
|
||||||
|
const [row2Entries, setRow2Entries] = useState<SubtitleEntry[]>(() => {
|
||||||
|
if (typeof window === "undefined") return DEFAULT_ROW2.map((k) => ({ key: k, under: k }));
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(LS_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
if (parsed.row2Entries) return parsed.row2Entries as SubtitleEntry[];
|
||||||
|
if (parsed.row2 && Array.isArray(parsed.row2)) {
|
||||||
|
// Migrate: old flat keys → entries with self as under
|
||||||
|
if (typeof parsed.row2[0] === "string") return parsed.row2.map((k: string) => ({ key: k, under: k }));
|
||||||
|
return parsed.row2 as SubtitleEntry[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return DEFAULT_ROW2.map((k) => ({ key: k, under: k }));
|
||||||
});
|
});
|
||||||
const [density, setDensity] = useState<Density>("comfortable");
|
const [density, setDensity] = useState<Density>("comfortable");
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("updated");
|
const [sortKey, setSortKey] = useState<SortKey>("updated");
|
||||||
const [resizingCol, setResizingCol] = useState<string | null>(null);
|
const [resizingCol, setResizingCol] = useState<string | null>(null);
|
||||||
|
const [colWidths, setColWidths] = useState<Record<string, number>>({});
|
||||||
const [colPickerOpen, setColPickerOpen] = useState(false);
|
const [colPickerOpen, setColPickerOpen] = useState(false);
|
||||||
|
|
||||||
// Persist columns to localStorage
|
// Persist layout to localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try { localStorage.setItem(LS_KEY, JSON.stringify(columns)); } catch { /* ignore */ }
|
try { localStorage.setItem(LS_KEY, JSON.stringify({ row1: row1Keys, row2Entries })); } catch { /* ignore */ }
|
||||||
}, [columns]);
|
}, [row1Keys, row2Entries]);
|
||||||
|
|
||||||
// Build available columns: base + custom fields
|
// Build available fields: base + custom fields
|
||||||
const availableColumns = useMemo(() => {
|
const allFields = useMemo(() => {
|
||||||
const base = baseColumns();
|
const cfFields: ColumnConfig[] = customFields
|
||||||
const cfCols: ColumnConfig[] = customFields
|
|
||||||
.filter((cf) => cf.key)
|
.filter((cf) => cf.key)
|
||||||
.map((cf) => ({
|
.map((cf) => ({ key: `cf.${cf.key}`, label: cf.name, width: 140 }));
|
||||||
key: `cf.${cf.key}`,
|
return [...ALL_FIELDS, ...cfFields];
|
||||||
label: cf.name,
|
}, [customFields]);
|
||||||
width: 140,
|
|
||||||
visible: columns.find((c) => c.key === `cf.${cf.key}`)?.visible ?? false,
|
const fieldByKey = useMemo(() => {
|
||||||
}));
|
const map = new Map<string, ColumnConfig>();
|
||||||
// Merge with current visibility state
|
for (const f of allFields) map.set(f.key, f);
|
||||||
const merged = base.map((bc) => {
|
return map;
|
||||||
const current = columns.find((c) => c.key === bc.key);
|
}, [allFields]);
|
||||||
return current ?? bc;
|
|
||||||
});
|
const row1Fields = row1Keys.map((k) => fieldByKey.get(k)).filter(Boolean) as ColumnConfig[];
|
||||||
for (const cf of cfCols) {
|
const row2EntriesResolved = row2Entries.filter((e) => fieldByKey.has(e.key));
|
||||||
const current = columns.find((c) => c.key === cf.key);
|
// Group subtitle entries by which column they sit under
|
||||||
merged.push(current ?? cf);
|
const subsByColumn = new Map<string, SubtitleEntry[]>();
|
||||||
|
for (const e of row2EntriesResolved) {
|
||||||
|
const list = subsByColumn.get(e.under) ?? [];
|
||||||
|
list.push(e);
|
||||||
|
subsByColumn.set(e.under, list);
|
||||||
}
|
}
|
||||||
return merged;
|
|
||||||
}, [customFields, columns]);
|
const colWidth = (key: string, fallback: number) => colWidths[key] ?? fallback;
|
||||||
|
|
||||||
// Saved views
|
// Saved views
|
||||||
const [savedViewsList, setSavedViewsList] = useState<SavedView[]>([]);
|
const [savedViewsList, setSavedViewsList] = useState<SavedView[]>([]);
|
||||||
@@ -268,6 +311,9 @@ function TicketWorkbenchContent() {
|
|||||||
const apiStatus = filters.find((f) => f.field === "status")?.value;
|
const apiStatus = filters.find((f) => f.field === "status")?.value;
|
||||||
const apiOwner = filters.find((f) => f.field === "owner")?.value;
|
const apiOwner = filters.find((f) => f.field === "owner")?.value;
|
||||||
const apiQueue = filters.find((f) => f.field === "queue")?.value;
|
const apiQueue = filters.find((f) => f.field === "queue")?.value;
|
||||||
|
const apiSubject = filters.find((f) => f.field === "subject");
|
||||||
|
const apiCreated = filters.find((f) => f.field === "created");
|
||||||
|
const apiUpdated = filters.find((f) => f.field === "updated");
|
||||||
const customFieldFilters: Record<string, string> = {};
|
const customFieldFilters: Record<string, string> = {};
|
||||||
for (const f of filters) {
|
for (const f of filters) {
|
||||||
if (f.field.startsWith("cf.")) {
|
if (f.field.startsWith("cf.")) {
|
||||||
@@ -276,19 +322,23 @@ function TicketWorkbenchContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const routeTeamId = searchParams.get("team_id") ?? "";
|
const routeTeamId = searchParams.get("team_id") ?? "";
|
||||||
const [ticketsRes, queuesRes, usersRes, fieldsRes, lifecycleRes] = await Promise.all([
|
const [ticketsRes, queuesRes, usersRes, fieldsRes, lifecycleRes, teamsRes] = await Promise.all([
|
||||||
getTickets({
|
getTickets({
|
||||||
q: searchQuery.trim() || undefined,
|
q: debouncedQuery.trim() || undefined,
|
||||||
status: apiStatus || undefined,
|
status: apiStatus || undefined,
|
||||||
queue_id: activeQueue || apiQueue || undefined,
|
queue_id: activeQueue || apiQueue || undefined,
|
||||||
owner_id: apiOwner || undefined,
|
owner_id: apiOwner || undefined,
|
||||||
team_id: routeTeamId || undefined,
|
team_id: routeTeamId || undefined,
|
||||||
custom_fields: Object.keys(customFieldFilters).length > 0 ? customFieldFilters : undefined,
|
custom_fields: Object.keys(customFieldFilters).length > 0 ? customFieldFilters : undefined,
|
||||||
|
subject: apiSubject ? `${apiSubject.operator}:${apiSubject.value}` : undefined,
|
||||||
|
created: apiCreated ? `${apiCreated.operator}:${apiCreated.value}` : undefined,
|
||||||
|
updated: apiUpdated ? `${apiUpdated.operator}:${apiUpdated.value}` : undefined,
|
||||||
}),
|
}),
|
||||||
getQueues(),
|
getQueues(),
|
||||||
getUsers(),
|
getUsers(),
|
||||||
getCustomFields(),
|
getCustomFields(),
|
||||||
getLifecycles(),
|
getLifecycles(),
|
||||||
|
getTeams(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (ticketsRes.error) {
|
if (ticketsRes.error) {
|
||||||
@@ -314,6 +364,12 @@ function TicketWorkbenchContent() {
|
|||||||
setUsers(usersRes.data ?? []);
|
setUsers(usersRes.data ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (teamsRes?.error) {
|
||||||
|
setError((current) => current ?? teamsRes.error);
|
||||||
|
} else if (teamsRes?.data) {
|
||||||
|
setTeamsList(teamsRes.data);
|
||||||
|
}
|
||||||
|
|
||||||
if (fieldsRes.error) {
|
if (fieldsRes.error) {
|
||||||
setError((current) => current ?? fieldsRes.error);
|
setError((current) => current ?? fieldsRes.error);
|
||||||
} else {
|
} else {
|
||||||
@@ -327,10 +383,11 @@ function TicketWorkbenchContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setInitialLoad(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
setClock(fetchedAt);
|
setClock(fetchedAt);
|
||||||
},
|
},
|
||||||
[filters, newQueueId, routeQueue, searchQuery]
|
[filters, newQueueId, routeQueue, debouncedQuery]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -422,7 +479,12 @@ function TicketWorkbenchContent() {
|
|||||||
);
|
);
|
||||||
if (view.sort_key) setSortKey(view.sort_key as SortKey);
|
if (view.sort_key) setSortKey(view.sort_key as SortKey);
|
||||||
if (view.columns && Array.isArray(view.columns) && view.columns.length > 0) {
|
if (view.columns && Array.isArray(view.columns) && view.columns.length > 0) {
|
||||||
setColumns(view.columns as ColumnConfig[]);
|
// Load row1/row2 from saved view columns if available, else fall back to default
|
||||||
|
const cols = view.columns as any[];
|
||||||
|
const r1 = cols.filter((c: any) => c.display !== "subtitle" && c.visible !== false).map((c: any) => c.key);
|
||||||
|
const r2 = cols.filter((c: any) => c.display === "subtitle").map((c: any) => c.key);
|
||||||
|
if (r1.length > 0) setRow1Keys(r1);
|
||||||
|
if (r2.length > 0) setRow2Entries(r2.map((k: string) => ({ key: k, under: k })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -430,7 +492,8 @@ function TicketWorkbenchContent() {
|
|||||||
// User navigated away from a view — clear filters and reset columns
|
// User navigated away from a view — clear filters and reset columns
|
||||||
setFilters([]);
|
setFilters([]);
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
setColumns(defaultColumns());
|
setRow1Keys(DEFAULT_ROW1);
|
||||||
|
setRow2Entries(DEFAULT_ROW2.map((k) => ({ key: k, under: k })));
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
@@ -485,7 +548,6 @@ function TicketWorkbenchContent() {
|
|||||||
}, [clock, inactiveStatuses, tickets]);
|
}, [clock, inactiveStatuses, tickets]);
|
||||||
|
|
||||||
const filteredTickets = useMemo(() => {
|
const filteredTickets = useMemo(() => {
|
||||||
const query = searchQuery.trim().toLowerCase();
|
|
||||||
const now = clock || 0;
|
const now = clock || 0;
|
||||||
const queue = routeQueue;
|
const queue = routeQueue;
|
||||||
const statusFilterValue = filters.find((f) => f.field === "status")?.value;
|
const statusFilterValue = filters.find((f) => f.field === "status")?.value;
|
||||||
@@ -506,13 +568,7 @@ function TicketWorkbenchContent() {
|
|||||||
if (statusFilterValue && ticket.status !== statusFilterValue) return false;
|
if (statusFilterValue && ticket.status !== statusFilterValue) return false;
|
||||||
if (queueFilterValue && ticket.queue_id !== queueFilterValue) return false;
|
if (queueFilterValue && ticket.queue_id !== queueFilterValue) return false;
|
||||||
if (queue && ticket.queue_id !== queue) return false;
|
if (queue && ticket.queue_id !== queue) return false;
|
||||||
if (!query) return true;
|
return true;
|
||||||
return (
|
|
||||||
ticket.subject.toLowerCase().includes(query) ||
|
|
||||||
formatTicketId(ticket.id).toLowerCase().includes(query) ||
|
|
||||||
statusLabel(ticket.status).toLowerCase().includes(query) ||
|
|
||||||
queueName(queues, ticket.queue_id).toLowerCase().includes(query)
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (sortKey === "id") return b.id - a.id;
|
if (sortKey === "id") return b.id - a.id;
|
||||||
@@ -520,7 +576,7 @@ function TicketWorkbenchContent() {
|
|||||||
const bDate = sortKey === "created" ? b.created_at : b.updated_at;
|
const bDate = sortKey === "created" ? b.created_at : b.updated_at;
|
||||||
return new Date(bDate).getTime() - new Date(aDate).getTime();
|
return new Date(bDate).getTime() - new Date(aDate).getTime();
|
||||||
});
|
});
|
||||||
}, [clock, filters, queues, routeQueue, searchQuery, sortKey, tickets, view]);
|
}, [clock, filters, queues, routeQueue, sortKey, tickets, view]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -538,24 +594,19 @@ function TicketWorkbenchContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBatchStatus = async (newStatus: string) => {
|
const handleBatchAction = async (update: { status?: string; owner_id?: string | null; team_id?: string | null }) => {
|
||||||
setBatchSaving(true);
|
setBatchSaving(true);
|
||||||
for (const id of batchIds) {
|
const ids = Array.from(batchIds);
|
||||||
await updateTicket(id, { status: newStatus });
|
const { data, error } = await batchUpdateTickets({ ticket_ids: ids, ...update });
|
||||||
}
|
|
||||||
setBatchSaving(false);
|
setBatchSaving(false);
|
||||||
setBatchIds(new Set());
|
if (!error && data) {
|
||||||
await fetchData();
|
const failed = data.results.filter((r) => !r.ok);
|
||||||
};
|
if (failed.length > 0) {
|
||||||
|
setError(`${failed.length} of ${ids.length} tickets failed to update`);
|
||||||
const handleBatchAssign = async () => {
|
}
|
||||||
const me = users[0]?.id;
|
} else if (error) {
|
||||||
if (!me) return;
|
setError(error);
|
||||||
setBatchSaving(true);
|
|
||||||
for (const id of batchIds) {
|
|
||||||
await updateTicket(id, { owner_id: me });
|
|
||||||
}
|
}
|
||||||
setBatchSaving(false);
|
|
||||||
setBatchIds(new Set());
|
setBatchIds(new Set());
|
||||||
await fetchData();
|
await fetchData();
|
||||||
};
|
};
|
||||||
@@ -572,30 +623,27 @@ function TicketWorkbenchContent() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setResizingCol(leftKey);
|
setResizingCol(leftKey);
|
||||||
const startX = e.clientX;
|
const startX = e.clientX;
|
||||||
const leftCol = columns.find((c) => c.key === leftKey);
|
const leftField = fieldByKey.get(leftKey);
|
||||||
const rightCol = rightKey ? columns.find((c) => c.key === rightKey) : null;
|
const rightField = rightKey ? fieldByKey.get(rightKey) : null;
|
||||||
const leftStart = leftCol?.width ?? 140;
|
const leftStart = colWidths[leftKey] ?? leftField?.width ?? 140;
|
||||||
const rightStart = rightCol?.width ?? 140;
|
const rightStart = rightField ? (colWidths[rightField.key] ?? rightField.width) : 140;
|
||||||
|
|
||||||
const onMove = (ev: MouseEvent) => {
|
const onMove = (ev: MouseEvent) => {
|
||||||
const delta = ev.clientX - startX;
|
const delta = ev.clientX - startX;
|
||||||
const newLeft = Math.max(50, Math.min(800, leftStart + delta));
|
const newLeft = Math.max(50, Math.min(800, leftStart + delta));
|
||||||
const newRight = rightCol ? Math.max(50, Math.min(800, rightStart - delta)) : undefined;
|
const newRight = rightField ? Math.max(50, Math.min(800, rightStart - delta)) : undefined;
|
||||||
setColumns((prev) =>
|
setColWidths((prev) => {
|
||||||
prev.map((c) => {
|
const next = { ...prev, [leftKey]: newLeft };
|
||||||
if (c.key === leftKey) return { ...c, width: newLeft };
|
if (rightField && newRight !== undefined) next[rightField.key] = newRight;
|
||||||
if (rightCol && c.key === rightCol.key) return { ...c, width: newRight! };
|
return next;
|
||||||
return c;
|
});
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
|
setResizingCol(null);
|
||||||
document.removeEventListener("mousemove", onMove);
|
document.removeEventListener("mousemove", onMove);
|
||||||
document.removeEventListener("mouseup", onUp);
|
document.removeEventListener("mouseup", onUp);
|
||||||
document.body.classList.remove("select-none");
|
|
||||||
setResizingCol(null);
|
|
||||||
};
|
};
|
||||||
document.body.classList.add("select-none");
|
|
||||||
document.addEventListener("mousemove", onMove);
|
document.addEventListener("mousemove", onMove);
|
||||||
document.addEventListener("mouseup", onUp);
|
document.addEventListener("mouseup", onUp);
|
||||||
};
|
};
|
||||||
@@ -659,7 +707,7 @@ function TicketWorkbenchContent() {
|
|||||||
if (data) router.push(`/tickets/${data.ticket.id}`);
|
if (data) router.push(`/tickets/${data.ticket.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <SkeletonWorkbench />;
|
if (initialLoad && loading) return <SkeletonWorkbench />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-background/80">
|
<div className="flex h-full flex-col bg-background/80">
|
||||||
@@ -676,6 +724,36 @@ function TicketWorkbenchContent() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
// Build CSV from visible columns
|
||||||
|
const allCols = [...row1Fields, ...(density === "comfortable" ? row2EntriesResolved.map((e) => fieldByKey.get(e.key)).filter(Boolean) as ColumnConfig[] : [])];
|
||||||
|
const headers = allCols.map((c) => c.label);
|
||||||
|
const rows = filteredTickets.map((ticket) => {
|
||||||
|
const ctx = { users, queues, teamsList };
|
||||||
|
return allCols.map((col) => {
|
||||||
|
if (col.key.startsWith("cf.")) {
|
||||||
|
const cfKey = col.key.slice(3);
|
||||||
|
return ticket.custom_fields?.find((v) => v.custom_field?.key === cfKey || v.custom_field?.name === cfKey)?.value ?? "";
|
||||||
|
}
|
||||||
|
const v = getSubtitleValue(col.key, ticket, ctx);
|
||||||
|
return v ?? "";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const csv = [headers.join(','), ...rows.map((r) => r.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(','))].join('\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = `tickets-${new Date().toISOString().slice(0,10)}.csv`;
|
||||||
|
a.click(); URL.revokeObjectURL(url);
|
||||||
|
}}
|
||||||
|
className="h-8 border-border/80 bg-card/70"
|
||||||
|
>
|
||||||
|
<DownloadIcon className="h-4 w-4" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -723,7 +801,7 @@ function TicketWorkbenchContent() {
|
|||||||
<input
|
<input
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(event) => setSearchQuery(event.target.value)}
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
placeholder="Search subject, ticket ID, queue, or status"
|
placeholder="Search tickets, comments, custom fields..."
|
||||||
className="h-9 w-full rounded-md border border-input bg-card/90 pl-9 pr-3 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring"
|
className="h-9 w-full rounded-md border border-input bg-card/90 pl-9 pr-3 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -830,7 +908,7 @@ function TicketWorkbenchContent() {
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<div>
|
<div className="flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
ref={addFilterBtnRef}
|
ref={addFilterBtnRef}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -846,6 +924,15 @@ function TicketWorkbenchContent() {
|
|||||||
<PlusIcon className="h-3 w-3" />
|
<PlusIcon className="h-3 w-3" />
|
||||||
Add filter
|
Add filter
|
||||||
</button>
|
</button>
|
||||||
|
{filters.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilters([])}
|
||||||
|
className="inline-flex h-7 items-center rounded px-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -881,6 +968,35 @@ function TicketWorkbenchContent() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{batchIds.size > 0 && (
|
||||||
|
<div className="flex items-center gap-3 border-b border-border bg-primary/5 px-4 py-2">
|
||||||
|
<span className="text-xs font-semibold text-foreground">{batchIds.size} selected</span>
|
||||||
|
<select
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val) { handleBatchAction({ status: val }); e.target.value = ""; }
|
||||||
|
}}
|
||||||
|
className="h-7 rounded border border-border/50 bg-card px-2 text-[11px] outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Set status...</option>
|
||||||
|
{Array.from(new Set(filteredTickets.map((t) => t.status))).map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => handleBatchAction({ owner_id: null })}
|
||||||
|
className="h-7 rounded border border-border/50 bg-card px-2 text-[11px] hover:bg-accent"
|
||||||
|
>
|
||||||
|
Unassign
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setBatchIds(new Set())}
|
||||||
|
className="ml-auto text-[11px] text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Table layout for consistent column alignment */}
|
{/* Table layout for consistent column alignment */}
|
||||||
<div style={{ display: "table", tableLayout: "fixed", minWidth: "100%" }}>
|
<div style={{ display: "table", tableLayout: "fixed", minWidth: "100%" }}>
|
||||||
{/* Column header */}
|
{/* Column header */}
|
||||||
@@ -889,11 +1005,11 @@ function TicketWorkbenchContent() {
|
|||||||
density === "compact" ? "min-h-7" : "min-h-8"
|
density === "compact" ? "min-h-7" : "min-h-8"
|
||||||
)} style={{ display: "table-row" }}>
|
)} style={{ display: "table-row" }}>
|
||||||
<div style={{ display: "table-cell", width: 48 }} />
|
<div style={{ display: "table-cell", width: 48 }} />
|
||||||
{availableColumns.filter((c) => c.visible).map((col, idx, arr) => (
|
{row1Fields.map((col, idx, arr) => (
|
||||||
<div
|
<div
|
||||||
key={col.key}
|
key={col.key}
|
||||||
className="relative border-r border-border/60 px-3 align-middle last:border-r-0"
|
className="relative border-r border-border/60 px-3 align-middle last:border-r-0"
|
||||||
style={{ display: "table-cell", width: col.width }}
|
style={{ display: "table-cell", width: colWidth(col.key, col.width) }}
|
||||||
>
|
>
|
||||||
{/* Resize handle: drags the boundary, resizes column to the LEFT */}
|
{/* Resize handle: drags the boundary, resizes column to the LEFT */}
|
||||||
{idx > 0 && (
|
{idx > 0 && (
|
||||||
@@ -910,6 +1026,39 @@ function TicketWorkbenchContent() {
|
|||||||
<div style={{ display: "table-cell", width: 48 }} />
|
<div style={{ display: "table-cell", width: 48 }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Subtitle header — labels for each row2 field under its matching column */}
|
||||||
|
{row2EntriesResolved.length > 0 && (
|
||||||
|
<div className="border-b border-border/30 bg-muted/30" style={{ display: "table-row" }}>
|
||||||
|
<div style={{ display: "table-cell", width: 48 }} />
|
||||||
|
{row1Fields.map((col) => {
|
||||||
|
const subsHere = subsByColumn.get(col.key) ?? [];
|
||||||
|
const orphans = col.key === "subject" ? row2EntriesResolved.filter((e) => !row1Fields.some((rf) => rf.key === e.under)) : [];
|
||||||
|
if (subsHere.length > 0 || orphans.length > 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.key}
|
||||||
|
className="px-3 py-0.5 align-middle"
|
||||||
|
style={{ display: "table-cell", width: colWidth(col.key, col.width) }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-[9px] font-medium uppercase text-muted-foreground/50">
|
||||||
|
{subsHere.map((e) => {
|
||||||
|
const f = fieldByKey.get(e.key);
|
||||||
|
return <span key={e.key}>{f?.label ?? e.key}</span>;
|
||||||
|
})}
|
||||||
|
{orphans.map((e) => {
|
||||||
|
const f = fieldByKey.get(e.key);
|
||||||
|
return <span key={e.key}>{f?.label ?? e.key}</span>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div key={col.key} style={{ display: "table-cell", width: colWidth(col.key, col.width) }} />;
|
||||||
|
})}
|
||||||
|
<div style={{ display: "table-cell", width: 48 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{filteredTickets.map((ticket) => {
|
{filteredTickets.map((ticket) => {
|
||||||
const selected = false;
|
const selected = false;
|
||||||
const ownerName = ticket.owner_id
|
const ownerName = ticket.owner_id
|
||||||
@@ -946,10 +1095,10 @@ function TicketWorkbenchContent() {
|
|||||||
title={statusLabel(ticket.status)}
|
title={statusLabel(ticket.status)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{availableColumns.filter((c) => c.visible).map((col) => {
|
{row1Fields.map((col) => {
|
||||||
const cellStyle = {
|
const cellStyle = {
|
||||||
display: "table-cell" as const,
|
display: "table-cell" as const,
|
||||||
width: col.width,
|
width: colWidth(col.key, col.width),
|
||||||
verticalAlign: "middle" as const,
|
verticalAlign: "middle" as const,
|
||||||
padding: density === "compact" ? "4px 12px" : "8px 12px",
|
padding: density === "compact" ? "4px 12px" : "8px 12px",
|
||||||
};
|
};
|
||||||
@@ -957,64 +1106,121 @@ function TicketWorkbenchContent() {
|
|||||||
if (col.key.startsWith("cf.")) {
|
if (col.key.startsWith("cf.")) {
|
||||||
const cfKey = col.key.slice(3);
|
const cfKey = col.key.slice(3);
|
||||||
const cfValue = ticket.custom_fields?.find((v) => v.custom_field?.key === cfKey || v.custom_field?.name === cfKey)?.value;
|
const cfValue = ticket.custom_fields?.find((v) => v.custom_field?.key === cfKey || v.custom_field?.name === cfKey)?.value;
|
||||||
|
const cfSubs = subsByColumn.get(col.key) ?? [];
|
||||||
return (
|
return (
|
||||||
<div key={col.key} className="truncate text-sm text-foreground" style={cellStyle}>
|
<div key={col.key} className="truncate text-sm text-foreground" style={cellStyle}>
|
||||||
{cfValue ?? "—"}
|
{cfValue ?? "—"}
|
||||||
|
{density === "comfortable" && cfSubs.map((e) => <div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
switch (col.key) {
|
switch (col.key) {
|
||||||
case "id":
|
case "id": {
|
||||||
|
const idSubs = subsByColumn.get("id") ?? [];
|
||||||
return (
|
return (
|
||||||
<div key={col.key} className="font-mono text-xs font-semibold text-muted-foreground" style={cellStyle}>
|
<div key={col.key} className="font-mono text-xs font-semibold text-muted-foreground" style={cellStyle}>
|
||||||
{formatTicketId(ticket.id)}
|
{formatTicketId(ticket.id)}
|
||||||
|
{density === "comfortable" && idSubs.map((e) => <div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case "subject":
|
}
|
||||||
|
case "subject": {
|
||||||
|
// Subtitle fields that don't have a matching row1 column
|
||||||
|
// Subtitle under subject + orphans (under column not in row1)
|
||||||
|
const subsHere = row2EntriesResolved.filter((e) =>
|
||||||
|
e.under === "subject" || !row1Fields.some((rf) => rf.key === e.under)
|
||||||
|
);
|
||||||
|
const subParts: string[] = [];
|
||||||
|
const ctx = { users, queues, teamsList };
|
||||||
|
for (const e of subsHere) {
|
||||||
|
const v = getSubtitleValue(e.key, ticket, ctx);
|
||||||
|
if (v) subParts.push(v);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div key={col.key} className="min-w-[200px]" style={cellStyle}>
|
<div key={col.key} className="min-w-[240px]" style={cellStyle}>
|
||||||
<span className="block truncate text-sm font-semibold text-foreground">
|
<span className="block truncate text-sm font-semibold text-foreground">
|
||||||
{ticket.subject}
|
{ticket.subject}
|
||||||
</span>
|
</span>
|
||||||
{density === "comfortable" && (
|
{density === "comfortable" && subParts.length > 0 && (
|
||||||
<span className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
{ownerName ?? "Unassigned"}
|
{subParts.map((part, i) => (
|
||||||
<span className="h-1 w-1 rounded-full bg-border" />
|
<span key={i} className="flex items-center gap-1.5">
|
||||||
Created {relativeTime(ticket.created_at)}
|
{i > 0 && <span className="h-1 w-1 rounded-full bg-border shrink-0" />}
|
||||||
|
{part}
|
||||||
</span>
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case "status":
|
case "status":
|
||||||
|
const statusSubs = subsByColumn.get("status") ?? [];
|
||||||
return (
|
return (
|
||||||
<div key={col.key} style={cellStyle}>
|
<div key={col.key} style={cellStyle}>
|
||||||
<TicketStatusBadge status={ticket.status} />
|
<TicketStatusBadge status={ticket.status} />
|
||||||
|
{density === "comfortable" && statusSubs.map((e) => (
|
||||||
|
<div key={e.key} className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{getSubtitleValue(e.key, ticket, { users, queues, teamsList })}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case "queue":
|
case "queue": {
|
||||||
|
const subs = subsByColumn.get("queue") ?? [];
|
||||||
return (
|
return (
|
||||||
<div key={col.key} className="truncate text-sm font-medium text-muted-foreground" style={cellStyle}>
|
<div key={col.key} className="truncate text-sm font-medium text-muted-foreground" style={cellStyle}>
|
||||||
{queueName(queues, ticket.queue_id)}
|
{queueName(queues, ticket.queue_id)}
|
||||||
|
{density === "comfortable" && subs.map((e) => (
|
||||||
|
<div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case "owner":
|
}
|
||||||
|
case "owner": {
|
||||||
|
const subs = subsByColumn.get("owner") ?? [];
|
||||||
return (
|
return (
|
||||||
<div key={col.key} className="truncate text-sm text-foreground" style={cellStyle}>
|
<div key={col.key} className="truncate text-sm text-foreground" style={cellStyle}>
|
||||||
{ownerName ?? "—"}
|
{ownerName ?? "—"}
|
||||||
|
{density === "comfortable" && subs.map((e) => (
|
||||||
|
<div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case "created":
|
}
|
||||||
|
case "created": {
|
||||||
|
const subs = subsByColumn.get("created") ?? [];
|
||||||
return (
|
return (
|
||||||
<div key={col.key} className="text-xs text-muted-foreground" style={cellStyle}>
|
<div key={col.key} className="text-xs text-muted-foreground" style={cellStyle}>
|
||||||
{relativeTime(ticket.created_at)}
|
{relativeTime(ticket.created_at)}
|
||||||
|
{density === "comfortable" && subs.map((e) => (
|
||||||
|
<div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case "updated":
|
}
|
||||||
|
case "updated": {
|
||||||
|
const subs = subsByColumn.get("updated") ?? [];
|
||||||
return (
|
return (
|
||||||
<div key={col.key} className="text-xs text-muted-foreground" style={cellStyle}>
|
<div key={col.key} className="text-xs text-muted-foreground" style={cellStyle}>
|
||||||
{relativeTime(ticket.updated_at)}
|
{relativeTime(ticket.updated_at)}
|
||||||
|
{density === "comfortable" && subs.map((e) => (
|
||||||
|
<div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
case "team": {
|
||||||
|
const subs = subsByColumn.get("team") ?? [];
|
||||||
|
return (
|
||||||
|
<div key={col.key} className="text-xs text-muted-foreground" style={cellStyle}>
|
||||||
|
{teamsList.find((t) => t.id === ticket.team_id)?.name ?? "—"}
|
||||||
|
{density === "comfortable" && subs.map((e) => (
|
||||||
|
<div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return <div key={col.key} style={cellStyle} />;
|
return <div key={col.key} style={cellStyle} />;
|
||||||
}
|
}
|
||||||
@@ -1188,7 +1394,7 @@ function TicketWorkbenchContent() {
|
|||||||
name: saveViewName.trim(),
|
name: saveViewName.trim(),
|
||||||
filters: storedFilters,
|
filters: storedFilters,
|
||||||
sort_key: sortKey,
|
sort_key: sortKey,
|
||||||
columns,
|
columns: [...row1Fields.map((f) => ({...f, display: "column"})), ...row2EntriesResolved.map((e) => ({key: e.key, under: e.under, display: "subtitle"}))] as any,
|
||||||
});
|
});
|
||||||
if (!error && data) {
|
if (!error && data) {
|
||||||
setSavedViewsList((prev) => [...prev, data]);
|
setSavedViewsList((prev) => [...prev, data]);
|
||||||
@@ -1220,7 +1426,7 @@ function TicketWorkbenchContent() {
|
|||||||
name: saveViewName.trim(),
|
name: saveViewName.trim(),
|
||||||
filters: storedFilters,
|
filters: storedFilters,
|
||||||
sort_key: sortKey,
|
sort_key: sortKey,
|
||||||
columns,
|
columns: [...row1Fields.map((f) => ({...f, display: "column"})), ...row2EntriesResolved.map((e) => ({key: e.key, under: e.under, display: "subtitle"}))] as any,
|
||||||
});
|
});
|
||||||
if (!error && data) {
|
if (!error && data) {
|
||||||
setSavedViewsList((prev) => [...prev, data]);
|
setSavedViewsList((prev) => [...prev, data]);
|
||||||
@@ -1250,32 +1456,26 @@ function TicketWorkbenchContent() {
|
|||||||
>
|
>
|
||||||
{!addFilterField ? (
|
{!addFilterField ? (
|
||||||
<>
|
<>
|
||||||
|
{[
|
||||||
|
{ field: "queue", label: "Queue" },
|
||||||
|
{ field: "owner", label: "Owner" },
|
||||||
|
{ field: "subject", label: "Subject" },
|
||||||
|
{ field: "created", label: "Created date" },
|
||||||
|
{ field: "updated", label: "Updated date" },
|
||||||
|
].map(({ field, label }) => (
|
||||||
<button
|
<button
|
||||||
|
key={field}
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!filters.find((f) => f.field === "queue")) {
|
if (field === "queue" && filters.find((f) => f.field === "queue")) { setAddFilterOpen(false); return; }
|
||||||
setAddFilterField("queue");
|
setAddFilterField(field);
|
||||||
setAddFilterOperator("is");
|
setAddFilterOperator(field === "created" || field === "updated" ? "before" : "contains");
|
||||||
setAddFilterValue("");
|
setAddFilterValue("");
|
||||||
} else {
|
|
||||||
setAddFilterOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>Queue</button>
|
>{label}</button>
|
||||||
<button
|
))}
|
||||||
type="button"
|
<div className="my-1 border-t border-border/30" />
|
||||||
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
|
||||||
onClick={() => {
|
|
||||||
if (!filters.find((f) => f.field === "owner")) {
|
|
||||||
setAddFilterField("owner");
|
|
||||||
setAddFilterOperator("is");
|
|
||||||
setAddFilterValue("");
|
|
||||||
} else {
|
|
||||||
setAddFilterOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>Owner</button>
|
|
||||||
{customFields.map((cf) => (
|
{customFields.map((cf) => (
|
||||||
<button
|
<button
|
||||||
key={`cf-portal-${cf.id}`}
|
key={`cf-portal-${cf.id}`}
|
||||||
@@ -1283,7 +1483,7 @@ function TicketWorkbenchContent() {
|
|||||||
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAddFilterField(`cf.${cf.key}`);
|
setAddFilterField(`cf.${cf.key}`);
|
||||||
setAddFilterOperator("is");
|
setAddFilterOperator(cf.field_type === "date" || cf.field_type === "datetime" ? "before" : "contains");
|
||||||
setAddFilterValue("");
|
setAddFilterValue("");
|
||||||
}}
|
}}
|
||||||
>{cf.name}</button>
|
>{cf.name}</button>
|
||||||
@@ -1295,26 +1495,55 @@ function TicketWorkbenchContent() {
|
|||||||
<button type="button" onClick={() => setAddFilterField(null)} className="text-muted-foreground hover:text-foreground">←</button>
|
<button type="button" onClick={() => setAddFilterField(null)} className="text-muted-foreground hover:text-foreground">←</button>
|
||||||
<span className="font-medium text-foreground">{addFilterField.startsWith("cf.") ? addFilterField.slice(3) : addFilterField}</span>
|
<span className="font-medium text-foreground">{addFilterField.startsWith("cf.") ? addFilterField.slice(3) : addFilterField}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{addFilterField === "queue" || addFilterField === "owner" ? (
|
||||||
<select value={addFilterOperator} onChange={(e) => setAddFilterOperator(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
|
<select value={addFilterOperator} onChange={(e) => setAddFilterOperator(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
|
||||||
<option value="is">is</option>
|
<option value="is">is</option>
|
||||||
<option value="is_not">is not</option>
|
<option value="is_not">is not</option>
|
||||||
</select>
|
</select>
|
||||||
|
) : addFilterField === "created" || addFilterField === "updated" ? (
|
||||||
|
<select value={addFilterOperator} onChange={(e) => setAddFilterOperator(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
|
||||||
|
<option value="before">before</option>
|
||||||
|
<option value="after">after</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<select value={addFilterOperator} onChange={(e) => setAddFilterOperator(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
|
||||||
|
<option value="contains">contains</option>
|
||||||
|
<option value="is">is</option>
|
||||||
|
<option value="is_not">is not</option>
|
||||||
|
<option value="starts_with">starts with</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
{addFilterField === "queue" ? (
|
{addFilterField === "queue" ? (
|
||||||
<select value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
|
<SearchableSelect
|
||||||
<option value="">Select queue...</option>
|
value={addFilterValue}
|
||||||
{queues.map((q) => (<option key={q.id} value={q.id}>{q.name}</option>))}
|
onChange={setAddFilterValue}
|
||||||
</select>
|
options={queues.map((q) => ({ value: q.id, label: q.name }))}
|
||||||
|
placeholder="Select queue..."
|
||||||
|
searchPlaceholder="Search queues..."
|
||||||
|
className="w-48"
|
||||||
|
/>
|
||||||
) : addFilterField === "owner" ? (
|
) : addFilterField === "owner" ? (
|
||||||
<select value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
|
<SearchableSelect
|
||||||
<option value="">Select owner...</option>
|
value={addFilterValue}
|
||||||
<option value="unassigned">Unassigned</option>
|
onChange={setAddFilterValue}
|
||||||
{users.map((u) => (<option key={u.id} value={u.id}>{u.username}</option>))}
|
options={[
|
||||||
</select>
|
{ value: "unassigned", label: "Unassigned" },
|
||||||
|
...users.map((u) => ({ value: u.id, label: u.username })),
|
||||||
|
]}
|
||||||
|
placeholder="Select owner..."
|
||||||
|
searchPlaceholder="Search users..."
|
||||||
|
className="w-48"
|
||||||
|
/>
|
||||||
|
) : addFilterField === "created" || addFilterField === "updated" ? (
|
||||||
|
<input type="date" value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none" />
|
||||||
) : (
|
) : (
|
||||||
<input value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} placeholder="Value" className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none"
|
<input value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} placeholder="Value" className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && addFilterValue.trim()) {
|
if (e.key === "Enter" && addFilterValue.trim()) {
|
||||||
setFilters((prev) => [...prev, { id: crypto.randomUUID(), field: addFilterField, operator: addFilterOperator, value: addFilterValue, label: buildFilterLabel(addFilterField, addFilterOperator, addFilterValue) }]);
|
const field = addFilterField!;
|
||||||
|
const value = addFilterValue;
|
||||||
|
let valueLabel = field === "created" || field === "updated" ? new Date(value).toLocaleDateString() : value;
|
||||||
|
setFilters((prev) => [...prev, { id: crypto.randomUUID(), field, operator: addFilterOperator, value, label: buildFilterLabel(field, addFilterOperator, valueLabel) }]);
|
||||||
setAddFilterField(null);
|
setAddFilterField(null);
|
||||||
setAddFilterOpen(false);
|
setAddFilterOpen(false);
|
||||||
}
|
}
|
||||||
@@ -1326,11 +1555,12 @@ function TicketWorkbenchContent() {
|
|||||||
<button type="button" disabled={!addFilterValue.trim()}
|
<button type="button" disabled={!addFilterValue.trim()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!addFilterValue.trim()) return;
|
if (!addFilterValue.trim()) return;
|
||||||
const field = addFilterField;
|
const field = addFilterField!;
|
||||||
const value = addFilterValue;
|
const value = addFilterValue;
|
||||||
let valueLabel = value;
|
let valueLabel = value;
|
||||||
if (field === "queue") valueLabel = queues.find((q) => q.id === value)?.name ?? value;
|
if (field === "queue") valueLabel = queues.find((q) => q.id === value)?.name ?? value;
|
||||||
else if (field === "owner") valueLabel = value === "unassigned" ? "Unassigned" : users.find((u) => u.id === value)?.username ?? value;
|
else if (field === "owner") valueLabel = value === "unassigned" ? "Unassigned" : users.find((u) => u.id === value)?.username ?? value;
|
||||||
|
else if (field === "created" || field === "updated") valueLabel = new Date(value).toLocaleDateString();
|
||||||
setFilters((prev) => [...prev, { id: crypto.randomUUID(), field, operator: addFilterOperator, value, label: buildFilterLabel(field, addFilterOperator, valueLabel) }]);
|
setFilters((prev) => [...prev, { id: crypto.randomUUID(), field, operator: addFilterOperator, value, label: buildFilterLabel(field, addFilterOperator, valueLabel) }]);
|
||||||
setAddFilterField(null);
|
setAddFilterField(null);
|
||||||
setAddFilterOpen(false);
|
setAddFilterOpen(false);
|
||||||
@@ -1346,33 +1576,16 @@ function TicketWorkbenchContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{typeof document !== "undefined" && colPickerOpen && createPortal(
|
{typeof document !== "undefined" && colPickerOpen && createPortal(
|
||||||
<>
|
<LayoutBuilder
|
||||||
<div className="fixed inset-0 z-[9998]" onClick={() => setColPickerOpen(false)} />
|
fields={allFields}
|
||||||
<div className="fixed z-[9999] w-48 rounded-md border border-border bg-card p-1 shadow-lg"
|
row1={row1Fields}
|
||||||
style={{ left: "calc(100% - 220px)", top: "72px" }}
|
row2={row2EntriesResolved}
|
||||||
>
|
onChange={(r1, r2) => {
|
||||||
{availableColumns.map((col) => {
|
setRow1Keys(r1.map((f) => f.key));
|
||||||
const isVisible = columns.find((c) => c.key === col.key)?.visible ?? col.visible;
|
setRow2Entries(r2);
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={col.key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setColumns((prev) =>
|
|
||||||
prev.map((c) => c.key === col.key ? { ...c, visible: !c.visible } : c)
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs text-foreground hover:bg-accent"
|
onClose={() => setColPickerOpen(false)}
|
||||||
>
|
/>,
|
||||||
<span className={cn("text-xs", isVisible ? "text-primary" : "text-muted-foreground/30")}>
|
|
||||||
{isVisible ? "✓" : "—"}
|
|
||||||
</span>
|
|
||||||
{col.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>,
|
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, use, useCallback } from "react";
|
import { useState, useEffect, use, useCallback, useRef } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow, format } from "date-fns";
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
BotIcon,
|
BotIcon,
|
||||||
@@ -12,11 +12,13 @@ import {
|
|||||||
CircleIcon,
|
CircleIcon,
|
||||||
Clock3Icon,
|
Clock3Icon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
|
Link2Icon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
SaveIcon,
|
SaveIcon,
|
||||||
SendIcon,
|
SendIcon,
|
||||||
|
Trash2Icon,
|
||||||
UserRoundIcon,
|
UserRoundIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -32,6 +34,12 @@ import {
|
|||||||
updateTicket,
|
updateTicket,
|
||||||
updateTicketCustomField,
|
updateTicketCustomField,
|
||||||
sendComment,
|
sendComment,
|
||||||
|
uploadAttachments,
|
||||||
|
getAttachmentUrl,
|
||||||
|
getTicketLinks,
|
||||||
|
createTicketLink,
|
||||||
|
deleteTicketLink,
|
||||||
|
mergeTickets,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import type {
|
import type {
|
||||||
Ticket,
|
Ticket,
|
||||||
@@ -43,8 +51,12 @@ import type {
|
|||||||
QueueCustomField,
|
QueueCustomField,
|
||||||
PreviewResult,
|
PreviewResult,
|
||||||
UpdateResult,
|
UpdateResult,
|
||||||
|
AttachmentUploadResult,
|
||||||
|
Attachment,
|
||||||
|
TicketLink,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { SearchableSelect } from "@/components/searchable-select";
|
||||||
import { cn, formatTicketId } from "@/lib/utils";
|
import { cn, formatTicketId } from "@/lib/utils";
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
@@ -101,6 +113,25 @@ function userLabel(users: User[], userId: string | null) {
|
|||||||
return user?.username ?? userId;
|
return user?.username ?? userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCfValue(value: string, fieldType: string): string {
|
||||||
|
if (!value) return "";
|
||||||
|
if (fieldType === "date") {
|
||||||
|
try {
|
||||||
|
return format(new Date(value), "MMM d, yyyy");
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fieldType === "datetime") {
|
||||||
|
try {
|
||||||
|
return format(new Date(value), "MMM d, yyyy HH:mm");
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function TransactionCard({
|
function TransactionCard({
|
||||||
tx,
|
tx,
|
||||||
users,
|
users,
|
||||||
@@ -117,6 +148,8 @@ function TransactionCard({
|
|||||||
tx.transaction_type === "SetOwner" ||
|
tx.transaction_type === "SetOwner" ||
|
||||||
tx.transaction_type === "SetTeam" ||
|
tx.transaction_type === "SetTeam" ||
|
||||||
tx.transaction_type === "CustomFieldChange" ||
|
tx.transaction_type === "CustomFieldChange" ||
|
||||||
|
tx.transaction_type === "LinkCreate" ||
|
||||||
|
tx.transaction_type === "LinkDelete" ||
|
||||||
tx.transaction_type === "Create";
|
tx.transaction_type === "Create";
|
||||||
const isInternal = tx.transaction_type === "Comment";
|
const isInternal = tx.transaction_type === "Comment";
|
||||||
const isMessage = tx.transaction_type === "Correspond" || isInternal;
|
const isMessage = tx.transaction_type === "Correspond" || isInternal;
|
||||||
@@ -141,6 +174,26 @@ function TransactionCard({
|
|||||||
} else if (tx.transaction_type === "CustomFieldChange") {
|
} else if (tx.transaction_type === "CustomFieldChange") {
|
||||||
const fieldName = tx.field ? customFieldLabels[tx.field] ?? "Custom field" : "Custom field";
|
const fieldName = tx.field ? customFieldLabels[tx.field] ?? "Custom field" : "Custom field";
|
||||||
message = tx.new_value ? `${fieldName} set to ${tx.new_value}` : `${fieldName} cleared`;
|
message = tx.new_value ? `${fieldName} set to ${tx.new_value}` : `${fieldName} cleared`;
|
||||||
|
} else if (tx.transaction_type === "LinkCreate") {
|
||||||
|
const targetId = typeof tx.data === "object" && tx.data !== null && "target_ticket_id" in (tx.data as Record<string, unknown>)
|
||||||
|
? Number((tx.data as Record<string, unknown>).target_ticket_id)
|
||||||
|
: null;
|
||||||
|
const targetSubject = typeof tx.data === "object" && tx.data !== null && "target_subject" in (tx.data as Record<string, unknown>)
|
||||||
|
? String((tx.data as Record<string, unknown>).target_subject)
|
||||||
|
: "";
|
||||||
|
const linkType = tx.field || "RelatedTo";
|
||||||
|
const targetLabel = targetId ? `${formatTicketId(targetId)}${targetSubject ? ` (${targetSubject})` : ""}` : "?";
|
||||||
|
message = `Linked as ${linkType} to ${targetLabel}`;
|
||||||
|
} else if (tx.transaction_type === "LinkDelete") {
|
||||||
|
const targetId = typeof tx.data === "object" && tx.data !== null && "target_ticket_id" in (tx.data as Record<string, unknown>)
|
||||||
|
? Number((tx.data as Record<string, unknown>).target_ticket_id)
|
||||||
|
: null;
|
||||||
|
const targetSubject = typeof tx.data === "object" && tx.data !== null && "target_subject" in (tx.data as Record<string, unknown>)
|
||||||
|
? String((tx.data as Record<string, unknown>).target_subject)
|
||||||
|
: "";
|
||||||
|
const linkType = tx.field || "RelatedTo";
|
||||||
|
const targetLabel = targetId ? `${formatTicketId(targetId)}${targetSubject ? ` (${targetSubject})` : ""}` : "?";
|
||||||
|
message = `Link ${linkType} to ${targetLabel} removed`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -172,10 +225,42 @@ function TransactionCard({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-[11px] text-muted-foreground/50">{timeAgo}</span>
|
<span className="text-[11px] text-muted-foreground/50">{timeAgo}</span>
|
||||||
|
{(tx.time_worked_minutes ?? 0) > 0 && (
|
||||||
|
<span className="rounded bg-emerald-500/10 px-1 py-0 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">
|
||||||
|
+{tx.time_worked_minutes}m
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">
|
<p className="mt-1.5 whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">
|
||||||
{body}
|
{body}
|
||||||
</p>
|
</p>
|
||||||
|
{tx.attachments && tx.attachments.length > 0 && (
|
||||||
|
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
||||||
|
{tx.attachments.map((att) => (
|
||||||
|
<a
|
||||||
|
key={att.id}
|
||||||
|
href={getAttachmentUrl(att.id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md border border-border/50 bg-card px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:border-primary/30 hover:bg-accent/30"
|
||||||
|
>
|
||||||
|
{att.mime_type.startsWith("image/") ? (
|
||||||
|
<FileTextIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<FileTextIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="truncate max-w-48">{att.filename}</span>
|
||||||
|
<span className="shrink-0 text-[10px] text-muted-foreground/70">
|
||||||
|
{att.size_bytes < 1024
|
||||||
|
? `${att.size_bytes}B`
|
||||||
|
: att.size_bytes < 1024 * 1024
|
||||||
|
? `${(att.size_bytes / 1024).toFixed(0)}KB`
|
||||||
|
: `${(att.size_bytes / (1024 * 1024)).toFixed(1)}MB`}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,8 +297,12 @@ export default function TicketDetailPage({
|
|||||||
|
|
||||||
const [replyText, setReplyText] = useState("");
|
const [replyText, setReplyText] = useState("");
|
||||||
const [replyMode, setReplyMode] = useState<"public" | "internal">("public");
|
const [replyMode, setReplyMode] = useState<"public" | "internal">("public");
|
||||||
|
const [timeMinutes, setTimeMinutes] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [sendError, setSendError] = useState<string | null>(null);
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
|
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||||
|
const [uploadingFiles, setUploadingFiles] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [statusSelectOpen, setStatusSelectOpen] = useState(false);
|
const [statusSelectOpen, setStatusSelectOpen] = useState(false);
|
||||||
const [pendingStatus, setPendingStatus] = useState<string | null>(null);
|
const [pendingStatus, setPendingStatus] = useState<string | null>(null);
|
||||||
@@ -224,6 +313,16 @@ export default function TicketDetailPage({
|
|||||||
const [editingSubject, setEditingSubject] = useState(false);
|
const [editingSubject, setEditingSubject] = useState(false);
|
||||||
const [subjectDraft, setSubjectDraft] = useState("");
|
const [subjectDraft, setSubjectDraft] = useState("");
|
||||||
const [fieldSaving, setFieldSaving] = useState<"subject" | "owner" | "team" | null>(null);
|
const [fieldSaving, setFieldSaving] = useState<"subject" | "owner" | "team" | null>(null);
|
||||||
|
const [links, setLinks] = useState<TicketLink[]>([]);
|
||||||
|
const [linkTargetId, setLinkTargetId] = useState("");
|
||||||
|
const [linkType, setLinkType] = useState("RelatedTo");
|
||||||
|
const [linkSaving, setLinkSaving] = useState(false);
|
||||||
|
const [linkError, setLinkError] = useState<string | null>(null);
|
||||||
|
const [linkDeleting, setLinkDeleting] = useState<string | null>(null);
|
||||||
|
const [mergeTargetId, setMergeTargetId] = useState("");
|
||||||
|
const [mergeSaving, setMergeSaving] = useState(false);
|
||||||
|
const [mergeError, setMergeError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [customFieldDrafts, setCustomFieldDrafts] = useState<Record<string, string>>({});
|
const [customFieldDrafts, setCustomFieldDrafts] = useState<Record<string, string>>({});
|
||||||
const [customFieldSaving, setCustomFieldSaving] = useState<string | null>(null);
|
const [customFieldSaving, setCustomFieldSaving] = useState<string | null>(null);
|
||||||
const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
|
const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
|
||||||
@@ -287,6 +386,14 @@ export default function TicketDetailPage({
|
|||||||
setTeams(teamsRes.data ?? []);
|
setTeams(teamsRes.data ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load ticket links
|
||||||
|
const linksRes = await getTicketLinks(id);
|
||||||
|
if (linksRes.error) {
|
||||||
|
setError((prev) => prev || linksRes.error);
|
||||||
|
} else {
|
||||||
|
setLinks(linksRes.data ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
@@ -466,13 +573,34 @@ export default function TicketDetailPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSendComment = async () => {
|
const handleSendComment = async () => {
|
||||||
if (!replyText.trim() || sending) return;
|
if ((!replyText.trim() && pendingFiles.length === 0) || sending) return;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
setSendError(null);
|
setSendError(null);
|
||||||
|
|
||||||
|
let attachmentIds: string[] = [];
|
||||||
|
|
||||||
|
// Upload files first if any
|
||||||
|
if (pendingFiles.length > 0) {
|
||||||
|
setUploadingFiles(true);
|
||||||
|
const { data, error } = await uploadAttachments(id, pendingFiles);
|
||||||
|
setUploadingFiles(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setSendError(error);
|
||||||
|
setSending(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
attachmentIds = data.attachments.map((a) => a.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { error } = await sendComment(id, {
|
const { error } = await sendComment(id, {
|
||||||
body: replyText.trim(),
|
body: replyText.trim() || "(attached files)",
|
||||||
internal: replyMode === "internal",
|
internal: replyMode === "internal",
|
||||||
|
attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined,
|
||||||
|
time_worked_minutes: timeMinutes.trim() ? Number(timeMinutes.trim()) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
setSending(false);
|
setSending(false);
|
||||||
@@ -481,12 +609,88 @@ export default function TicketDetailPage({
|
|||||||
setSendError(error);
|
setSendError(error);
|
||||||
} else {
|
} else {
|
||||||
setReplyText("");
|
setReplyText("");
|
||||||
|
setTimeMinutes("");
|
||||||
|
setPendingFiles([]);
|
||||||
setSendError(null);
|
setSendError(null);
|
||||||
const txRes = await getTicketTransactions(id);
|
const txRes = await getTicketTransactions(id);
|
||||||
if (txRes.data) setTransactions(txRes.data);
|
if (txRes.data) setTransactions(txRes.data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateLink = async () => {
|
||||||
|
// Accept both raw numbers and TKT-XXXX format
|
||||||
|
const raw = linkTargetId.trim().replace(/^TKT-0*/i, '');
|
||||||
|
const targetId = Number(raw);
|
||||||
|
if (!raw || isNaN(targetId)) {
|
||||||
|
setLinkError('Enter a ticket ID (e.g. "42" or "TKT-0042")');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (linkSaving) return;
|
||||||
|
|
||||||
|
setLinkSaving(true);
|
||||||
|
setLinkError(null);
|
||||||
|
|
||||||
|
const { data, error } = await createTicketLink(id, {
|
||||||
|
target_ticket_id: targetId,
|
||||||
|
link_type: linkType,
|
||||||
|
});
|
||||||
|
|
||||||
|
setLinkSaving(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setLinkError(error);
|
||||||
|
} else {
|
||||||
|
setLinkTargetId("");
|
||||||
|
setLinkType("RelatedTo");
|
||||||
|
// Refresh links and transactions
|
||||||
|
const [linksRes, txRes] = await Promise.all([
|
||||||
|
getTicketLinks(id),
|
||||||
|
getTicketTransactions(id),
|
||||||
|
]);
|
||||||
|
if (linksRes.data) setLinks(linksRes.data);
|
||||||
|
if (txRes.data) setTransactions(txRes.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteLink = async (linkId: string) => {
|
||||||
|
if (linkDeleting) return;
|
||||||
|
setLinkDeleting(linkId);
|
||||||
|
|
||||||
|
const { error } = await deleteTicketLink(id, linkId);
|
||||||
|
|
||||||
|
setLinkDeleting(null);
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
const [linksRes, txRes] = await Promise.all([
|
||||||
|
getTicketLinks(id),
|
||||||
|
getTicketTransactions(id),
|
||||||
|
]);
|
||||||
|
if (linksRes.data) setLinks(linksRes.data);
|
||||||
|
if (txRes.data) setTransactions(txRes.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMerge = async () => {
|
||||||
|
const targetId = Number(mergeTargetId.trim().replace(/^TKT-0*/i, ''));
|
||||||
|
if (!targetId || isNaN(targetId) || mergeSaving) return;
|
||||||
|
setMergeSaving(true);
|
||||||
|
setMergeError(null);
|
||||||
|
const { data, error } = await mergeTickets(id, targetId);
|
||||||
|
setMergeSaving(false);
|
||||||
|
if (error) {
|
||||||
|
setMergeError(error);
|
||||||
|
} else if (data?.ok) {
|
||||||
|
setMergeTargetId("");
|
||||||
|
// Reload ticket and transactions
|
||||||
|
const [ticketRes, txRes] = await Promise.all([
|
||||||
|
getTicket(id),
|
||||||
|
getTicketTransactions(id),
|
||||||
|
]);
|
||||||
|
if (ticketRes.data) setTicket(ticketRes.data);
|
||||||
|
if (txRes.data) setTransactions(txRes.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full grid-cols-1 xl:grid-cols-[minmax(0,1fr)_348px]">
|
<div className="grid h-full grid-cols-1 xl:grid-cols-[minmax(0,1fr)_348px]">
|
||||||
@@ -546,10 +750,13 @@ export default function TicketDetailPage({
|
|||||||
const currentStatusColor = STATUS_COLORS[ticket.status] || STATUS_COLORS.new;
|
const currentStatusColor = STATUS_COLORS[ticket.status] || STATUS_COLORS.new;
|
||||||
const currentStatusLabel = statusLabel(ticket.status);
|
const currentStatusLabel = statusLabel(ticket.status);
|
||||||
const customFieldLabels = Object.fromEntries(
|
const customFieldLabels = Object.fromEntries(
|
||||||
queueFields.map((assignment) => [
|
queueFields.flatMap((assignment) => {
|
||||||
assignment.custom_field_id,
|
const name = assignment.custom_field?.name ?? assignment.custom_field_id;
|
||||||
assignment.custom_field?.name ?? assignment.custom_field_id,
|
const key = assignment.custom_field?.key;
|
||||||
])
|
const entries: [string, string][] = [[assignment.custom_field_id, name]];
|
||||||
|
if (key) entries.push([key, name]);
|
||||||
|
return entries;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -707,6 +914,28 @@ export default function TicketDetailPage({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{pendingFiles.length > 0 && (
|
||||||
|
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||||
|
{pendingFiles.map((file, i) => (
|
||||||
|
<span
|
||||||
|
key={`${file.name}-${i}`}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-accent/20 px-2 py-0.5 text-xs text-foreground"
|
||||||
|
>
|
||||||
|
<FileTextIcon className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="truncate max-w-32">{file.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPendingFiles((prev) => prev.filter((_, j) => j !== i))}
|
||||||
|
className="ml-0.5 flex h-4 w-4 items-center justify-center rounded text-muted-foreground hover:bg-destructive/20 hover:text-destructive"
|
||||||
|
title="Remove file"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<XIcon className="h-2.5 w-2.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
value={replyText}
|
value={replyText}
|
||||||
@@ -719,19 +948,46 @@ export default function TicketDetailPage({
|
|||||||
className="min-h-24 flex-1 resize-none rounded-md border border-input bg-card/90 px-3 py-2 text-sm leading-6 text-foreground shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring"
|
className="min-h-24 flex-1 resize-none rounded-md border border-input bg-card/90 px-3 py-2 text-sm leading-6 text-foreground shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
value={timeMinutes}
|
||||||
|
onChange={(e) => setTimeMinutes(e.target.value.replace(/\D/g, ''))}
|
||||||
|
placeholder="min"
|
||||||
|
className="h-9 w-14 rounded-md border border-input bg-card/90 px-2 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-ring"
|
||||||
|
title="Time worked (minutes)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
const selected = Array.from(event.target.files ?? []);
|
||||||
|
if (selected.length > 0) {
|
||||||
|
setPendingFiles((prev) => [...prev, ...selected]);
|
||||||
|
}
|
||||||
|
// Reset so the same file can be re-selected
|
||||||
|
event.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
className="flex h-9 w-9 items-center justify-center rounded-md border border-border bg-card text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
onClick={() => fileInputRef.current?.click()}
|
||||||
title="Attach file (coming soon)"
|
className={cn(
|
||||||
|
"flex h-9 w-9 items-center justify-center rounded-md border border-border bg-card transition-colors hover:bg-accent",
|
||||||
|
pendingFiles.length > 0
|
||||||
|
? "text-primary border-primary/50"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
title="Attach files"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<PaperclipIcon className="h-4 w-4" />
|
<PaperclipIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSendComment}
|
onClick={handleSendComment}
|
||||||
disabled={!replyText.trim() || sending}
|
disabled={(!replyText.trim() && pendingFiles.length === 0) || sending}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-9 items-center justify-center rounded-md transition-colors",
|
"flex h-9 w-9 items-center justify-center rounded-md transition-colors",
|
||||||
replyText.trim() && !sending
|
(replyText.trim() || pendingFiles.length > 0) && !sending
|
||||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
: "bg-muted text-muted-foreground"
|
: "bg-muted text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
@@ -749,6 +1005,14 @@ export default function TicketDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{pendingFiles.length > 0 && (
|
||||||
|
<p className="mt-1.5 text-[10px] text-muted-foreground/70">
|
||||||
|
{pendingFiles.length} file{pendingFiles.length !== 1 ? "s" : ""} selected — will be uploaded when you send
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{uploadingFiles && (
|
||||||
|
<p className="mt-1.5 text-[10px] text-muted-foreground/70">Uploading files...</p>
|
||||||
|
)}
|
||||||
{sendError && <p className="mt-2 text-xs text-destructive">{sendError}</p>}
|
{sendError && <p className="mt-2 text-xs text-destructive">{sendError}</p>}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -803,6 +1067,30 @@ export default function TicketDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{ticket.blocked_by && ticket.blocked_by.length > 0 && (
|
||||||
|
<section className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-semibold text-foreground">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-amber-500" /> Blocked
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||||
|
This ticket depends on unresolved tickets:
|
||||||
|
</p>
|
||||||
|
<div className="mt-1.5 space-y-0.5">
|
||||||
|
{ticket.blocked_by.map((b) => (
|
||||||
|
<Link
|
||||||
|
key={b.id}
|
||||||
|
href={`/tickets/${b.id}`}
|
||||||
|
className="flex items-center gap-1.5 text-[11px] text-foreground hover:text-primary"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[10px]">{formatTicketId(b.id)}</span>
|
||||||
|
<span className="truncate">{b.subject}</span>
|
||||||
|
<span className="shrink-0 text-[10px] text-muted-foreground">{b.status}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{preview && (
|
{preview && (
|
||||||
<section className="rounded-lg border border-border bg-accent/20 p-3">
|
<section className="rounded-lg border border-border bg-accent/20 p-3">
|
||||||
<div className="flex items-center gap-2 text-xs font-semibold text-foreground">
|
<div className="flex items-center gap-2 text-xs font-semibold text-foreground">
|
||||||
@@ -851,29 +1139,27 @@ export default function TicketDetailPage({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-[10px] font-medium text-muted-foreground">Owner</label>
|
<label className="mb-1 block text-[10px] font-medium text-muted-foreground">Owner</label>
|
||||||
<select
|
<SearchableSelect
|
||||||
value={ticket.owner_id ?? ""}
|
value={ticket.owner_id ?? ""}
|
||||||
onChange={(event) => void handleOwnerChange(event.target.value)}
|
onChange={(val) => void handleOwnerChange(val)}
|
||||||
|
options={users.map((u) => ({ value: u.id, label: u.username }))}
|
||||||
|
placeholder="Unassigned"
|
||||||
|
searchPlaceholder="Search users..."
|
||||||
disabled={fieldSaving === "owner"}
|
disabled={fieldSaving === "owner"}
|
||||||
className="h-8 w-full rounded-md border border-input bg-transparent px-2.5 text-sm text-foreground outline-none focus:border-ring disabled:opacity-50"
|
clearLabel="Unassigned"
|
||||||
aria-label="Owner"
|
/>
|
||||||
>
|
|
||||||
<option value="">Unassigned</option>
|
|
||||||
{users.map((user) => (<option key={user.id} value={user.id}>{user.username}</option>))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-[10px] font-medium text-muted-foreground">Team</label>
|
<label className="mb-1 block text-[10px] font-medium text-muted-foreground">Team</label>
|
||||||
<select
|
<SearchableSelect
|
||||||
value={ticket.team_id ?? ""}
|
value={ticket.team_id ?? ""}
|
||||||
onChange={(event) => void handleTeamChange(event.target.value)}
|
onChange={(val) => void handleTeamChange(val)}
|
||||||
|
options={teams.map((t) => ({ value: t.id, label: t.name }))}
|
||||||
|
placeholder="No team"
|
||||||
|
searchPlaceholder="Search teams..."
|
||||||
disabled={fieldSaving === "team"}
|
disabled={fieldSaving === "team"}
|
||||||
className="h-8 w-full rounded-md border border-input bg-transparent px-2.5 text-sm text-foreground outline-none focus:border-ring disabled:opacity-50"
|
clearLabel="No team"
|
||||||
aria-label="Team"
|
/>
|
||||||
>
|
|
||||||
<option value="">No team</option>
|
|
||||||
{teams.map((team) => (<option key={team.id} value={team.id}>{team.name}</option>))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -900,9 +1186,117 @@ export default function TicketDetailPage({
|
|||||||
<span className="text-foreground">{formatDistanceToNow(new Date(ticket.resolved_at), { addSuffix: true })}</span>
|
<span className="text-foreground">{formatDistanceToNow(new Date(ticket.resolved_at), { addSuffix: true })}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex items-baseline justify-between gap-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">Time worked</span>
|
||||||
|
<span className="text-foreground">
|
||||||
|
{(() => {
|
||||||
|
const totalMin = transactions.reduce((sum, tx) => sum + (tx.time_worked_minutes ?? 0), 0);
|
||||||
|
if (totalMin < 60) return `${totalMin}m`;
|
||||||
|
const h = Math.floor(totalMin / 60);
|
||||||
|
const m = totalMin % 60;
|
||||||
|
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Linked tickets */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Linked tickets</h2>
|
||||||
|
{links.length === 0 && !linkSaving && (
|
||||||
|
<p className="text-xs text-muted-foreground">No linked tickets.</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{links.map((link) => (
|
||||||
|
<div
|
||||||
|
key={link.id}
|
||||||
|
className="group flex items-center gap-1.5 rounded px-1.5 py-1 -mx-1.5 transition-colors hover:bg-accent/30"
|
||||||
|
>
|
||||||
|
<Link2Icon className="h-3 w-3 shrink-0 text-muted-foreground/60" />
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground shrink-0">{link.link_type}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground/50 shrink-0">→</span>
|
||||||
|
{link.target_ticket ? (
|
||||||
|
<Link
|
||||||
|
href={`/tickets/${link.target_ticket_id}`}
|
||||||
|
className="font-mono text-[11px] font-semibold text-foreground shrink-0 transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
{formatTicketId(link.target_ticket_id)}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="font-mono text-[11px] text-muted-foreground shrink-0">{link.target_ticket_id}</span>
|
||||||
|
)}
|
||||||
|
{link.target_ticket && (
|
||||||
|
<span className="min-w-0 truncate text-xs text-muted-foreground">
|
||||||
|
{link.target_ticket.subject}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteLink(link.id)}
|
||||||
|
disabled={linkDeleting === link.id}
|
||||||
|
className="ml-auto flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/20 hover:text-destructive group-hover:opacity-100 disabled:opacity-50"
|
||||||
|
title="Remove link"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{linkDeleting === link.id ? (
|
||||||
|
<div className="h-2.5 w-2.5 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
<Trash2Icon className="h-2.5 w-2.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline link form */}
|
||||||
|
<div className="mt-2.5 space-y-2 rounded-md border border-border/30 p-2">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<input
|
||||||
|
value={linkTargetId}
|
||||||
|
onChange={(event) => {
|
||||||
|
setLinkTargetId(event.target.value);
|
||||||
|
setLinkError(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") handleCreateLink();
|
||||||
|
}}
|
||||||
|
placeholder="Ticket ID..."
|
||||||
|
className="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-ring"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={linkType}
|
||||||
|
onChange={(event) => setLinkType(event.target.value)}
|
||||||
|
className="h-7 w-28 shrink-0 rounded-md border border-input bg-transparent px-1.5 text-sm text-foreground outline-none focus:border-ring"
|
||||||
|
>
|
||||||
|
<option value="RelatedTo">Related to</option>
|
||||||
|
<option value="DependsOn">Depends on</option>
|
||||||
|
<option value="Blocks">Blocks</option>
|
||||||
|
<option value="RefersTo">Refers to</option>
|
||||||
|
<option value="Duplicates">Duplicates</option>
|
||||||
|
<option value="MemberOf">Member of</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateLink}
|
||||||
|
disabled={!linkTargetId.trim() || linkSaving}
|
||||||
|
className="flex h-6.5 w-full items-center justify-center gap-1 rounded-md bg-primary text-[11px] font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{linkSaving ? (
|
||||||
|
<div className="h-3 w-3 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
<Link2Icon className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Link
|
||||||
|
</button>
|
||||||
|
{linkError && <p className="text-[10px] text-destructive">{linkError}</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
{/* Custom fields — flat, no heavy borders */}
|
{/* Custom fields — flat, no heavy borders */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Custom fields</h2>
|
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Custom fields</h2>
|
||||||
@@ -944,6 +1338,31 @@ export default function TicketDetailPage({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
) : field?.field_type === 'date' ? (
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={draftValue}
|
||||||
|
onChange={(event) => {
|
||||||
|
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: event.target.value }));
|
||||||
|
void handleCustomFieldSave(fieldId, event.target.value);
|
||||||
|
}}
|
||||||
|
onBlur={() => setEditingFieldId(null)}
|
||||||
|
autoFocus
|
||||||
|
className="h-8 flex-1 rounded-md border border-ring bg-card px-2 text-sm text-foreground outline-none"
|
||||||
|
/>
|
||||||
|
) : field?.field_type === 'datetime' ? (
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={draftValue ? draftValue.slice(0, 16) : ""}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = event.target.value ? new Date(event.target.value).toISOString() : "";
|
||||||
|
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: nextValue }));
|
||||||
|
void handleCustomFieldSave(fieldId, nextValue);
|
||||||
|
}}
|
||||||
|
onBlur={() => setEditingFieldId(null)}
|
||||||
|
autoFocus
|
||||||
|
className="h-8 flex-1 rounded-md border border-ring bg-card px-2 text-sm text-foreground outline-none"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
value={draftValue}
|
value={draftValue}
|
||||||
@@ -1001,7 +1420,7 @@ export default function TicketDetailPage({
|
|||||||
currentValue ? "text-foreground" : "text-muted-foreground"
|
currentValue ? "text-foreground" : "text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{currentValue || "Not set"}
|
{currentValue ? formatCfValue(currentValue, field?.field_type ?? "text") : "Not set"}
|
||||||
</span>
|
</span>
|
||||||
<PencilIcon className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
<PencilIcon className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1013,6 +1432,34 @@ export default function TicketDetailPage({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Merge */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Merge</h2>
|
||||||
|
<p className="mb-2 text-xs text-muted-foreground">
|
||||||
|
Move all transactions, attachments, and links into another ticket. This ticket will be closed.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
value={mergeTargetId}
|
||||||
|
onChange={(e) => { setMergeTargetId(e.target.value); setMergeError(null); }}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") handleMerge(); }}
|
||||||
|
placeholder="Target ticket ID..."
|
||||||
|
className="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground focus:border-ring"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleMerge}
|
||||||
|
disabled={!mergeTargetId.trim() || mergeSaving}
|
||||||
|
className="h-7 shrink-0 rounded-md bg-destructive/90 px-2.5 text-[11px] font-semibold text-destructive-foreground hover:bg-destructive disabled:opacity-50"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{mergeSaving ? "..." : "Merge"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{mergeError && <p className="mt-1.5 text-[10px] text-destructive">{mergeError}</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, Suspense, createContext, useContext } from "react"
|
|||||||
import { usePathname, useSearchParams } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
|
BellIcon,
|
||||||
CircleIcon,
|
CircleIcon,
|
||||||
LayoutGridIcon,
|
LayoutGridIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
@@ -15,11 +16,12 @@ import {
|
|||||||
PanelLeftIcon,
|
PanelLeftIcon,
|
||||||
CommandIcon,
|
CommandIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getTickets, getQueues, getViews, getDashboards, getUsers, getTeams, createDashboard } from "@/lib/api";
|
import { getTickets, getQueues, getViews, getDashboards, getUsers, getTeams, createDashboard, getUnreadCount, getNotifications, markNotificationRead, markAllNotificationsRead, getApiTokens, createApiToken, revokeApiToken } from "@/lib/api";
|
||||||
import type { Dashboard, Queue, SavedView, Team, User } from "@/lib/types";
|
import type { Dashboard, Queue, SavedView, Team, User, Notification, ApiToken } from "@/lib/types";
|
||||||
import { CommandPalette } from "@/components/command-palette";
|
import { CommandPalette } from "@/components/command-palette";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
import { cn } from "@/lib/utils";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
import { cn, formatTicketId } from "@/lib/utils";
|
||||||
|
|
||||||
const SidebarCollapsedContext = createContext(false);
|
const SidebarCollapsedContext = createContext(false);
|
||||||
|
|
||||||
@@ -77,6 +79,7 @@ function SidebarNavItem({
|
|||||||
function SidebarNav() {
|
function SidebarNav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const { user: authUser } = useAuth();
|
||||||
|
|
||||||
const [counts, setCounts] = useState<ViewCounts>({
|
const [counts, setCounts] = useState<ViewCounts>({
|
||||||
all: 0,
|
all: 0,
|
||||||
@@ -87,20 +90,17 @@ function SidebarNav() {
|
|||||||
const [queues, setQueues] = useState<(Queue & { count: number })[]>([]);
|
const [queues, setQueues] = useState<(Queue & { count: number })[]>([]);
|
||||||
const [savedViews, setSavedViews] = useState<SavedView[]>([]);
|
const [savedViews, setSavedViews] = useState<SavedView[]>([]);
|
||||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
|
||||||
const [myTeamId, setMyTeamId] = useState<string | null>(null);
|
const [myTeamId, setMyTeamId] = useState<string | null>(null);
|
||||||
const [newDashboardName, setNewDashboardName] = useState("");
|
const [newDashboardName, setNewDashboardName] = useState("");
|
||||||
const [addingDashboard, setAddingDashboard] = useState(false);
|
const [addingDashboard, setAddingDashboard] = useState(false);
|
||||||
|
|
||||||
|
const currentUserId = authUser?.id ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
// Find current user
|
const myId = currentUserId;
|
||||||
const [ticketRes, userRes] = await Promise.all([getTickets(), getUsers()]);
|
const [ticketRes, userRes] = await Promise.all([getTickets(), getUsers()]);
|
||||||
const data = ticketRes.data;
|
const data = ticketRes.data;
|
||||||
const users = userRes.data ?? [];
|
|
||||||
const currentUser = users.find((u) => u.username !== 'system') ?? users[0] ?? null;
|
|
||||||
const myId = currentUser?.id ?? null;
|
|
||||||
setCurrentUserId(myId);
|
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -135,9 +135,9 @@ function SidebarNav() {
|
|||||||
const [dashRes, teamRes] = await Promise.all([getDashboards(), getTeams()]);
|
const [dashRes, teamRes] = await Promise.all([getDashboards(), getTeams()]);
|
||||||
const allDashboards = dashRes.data ?? [];
|
const allDashboards = dashRes.data ?? [];
|
||||||
const allTeams = teamRes.data ?? [];
|
const allTeams = teamRes.data ?? [];
|
||||||
const userTeams = allTeams.filter((t) =>
|
const userTeams = myId
|
||||||
(t.members ?? []).some((m) => m.id === myId)
|
? allTeams.filter((t) => (t.members ?? []).some((m) => m.id === myId))
|
||||||
);
|
: [];
|
||||||
setMyTeamId(userTeams[0]?.id ?? null);
|
setMyTeamId(userTeams[0]?.id ?? null);
|
||||||
const teamIds = new Set(userTeams.map((t) => t.id));
|
const teamIds = new Set(userTeams.map((t) => t.id));
|
||||||
const visible = allDashboards.filter((d) =>
|
const visible = allDashboards.filter((d) =>
|
||||||
@@ -146,7 +146,7 @@ function SidebarNav() {
|
|||||||
setDashboards(visible);
|
setDashboards(visible);
|
||||||
}
|
}
|
||||||
void load();
|
void load();
|
||||||
}, []);
|
}, [currentUserId]);
|
||||||
|
|
||||||
const collapsed = useSidebarCollapsed();
|
const collapsed = useSidebarCollapsed();
|
||||||
|
|
||||||
@@ -326,22 +326,268 @@ function SidebarNav() {
|
|||||||
function SidebarBottom() {
|
function SidebarBottom() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const collapsed = useSidebarCollapsed();
|
const collapsed = useSidebarCollapsed();
|
||||||
|
const { user, logout, isAdmin } = useAuth();
|
||||||
|
const [tokenOpen, setTokenOpen] = useState(false);
|
||||||
|
const [tokens, setTokens] = useState<ApiToken[]>([]);
|
||||||
|
const [newTokenName, setNewTokenName] = useState("");
|
||||||
|
const [newTokenValue, setNewTokenValue] = useState<string | null>(null);
|
||||||
|
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadTokens = async () => {
|
||||||
|
const { data } = await getApiTokens();
|
||||||
|
if (data) setTokens(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { if (tokenOpen) { void loadTokens(); } }, [tokenOpen]);
|
||||||
|
|
||||||
|
const handleCreateToken = async () => {
|
||||||
|
if (!newTokenName.trim()) return;
|
||||||
|
setTokenError(null);
|
||||||
|
const { data, error } = await createApiToken(newTokenName.trim());
|
||||||
|
if (error) { setTokenError(error); return; }
|
||||||
|
if (data) {
|
||||||
|
setNewTokenValue(data.token);
|
||||||
|
setNewTokenName("");
|
||||||
|
await loadTokens();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevoke = async (id: string) => {
|
||||||
|
await revokeApiToken(id);
|
||||||
|
await loadTokens();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-sidebar-border/50 p-2">
|
<div className="border-t border-sidebar-border/50 p-2 space-y-1">
|
||||||
|
{isAdmin && (
|
||||||
<SidebarNavItem
|
<SidebarNavItem
|
||||||
href="/admin"
|
href="/admin"
|
||||||
icon={SettingsIcon}
|
icon={SettingsIcon}
|
||||||
label="Admin"
|
label="Admin"
|
||||||
active={pathname === "/admin"}
|
active={pathname === "/admin"}
|
||||||
/>
|
/>
|
||||||
<div className={cn("flex", collapsed ? "justify-center mt-2" : "mt-2 px-1")}>
|
)}
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="px-2.5 py-1 text-[11px] text-sidebar-foreground/50 truncate">
|
||||||
|
{user.username}
|
||||||
|
{isAdmin && <span className="ml-1 text-[10px] text-sidebar-foreground/30">(admin)</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setTokenOpen(true)}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
|
||||||
|
"text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50",
|
||||||
|
collapsed ? "justify-center" : ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="opacity-50">API tokens</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
|
||||||
|
"text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50",
|
||||||
|
collapsed ? "justify-center" : ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="opacity-50">Sign out</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Token dialog */}
|
||||||
|
{tokenOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => { setTokenOpen(false); setNewTokenValue(null); }} />
|
||||||
|
<div className="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-popover shadow-xl">
|
||||||
|
<div className="border-b border-border/50 px-4 py-3">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">API tokens</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{newTokenValue ? (
|
||||||
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
|
||||||
|
<p className="text-xs font-semibold text-foreground">Token created — copy it now:</p>
|
||||||
|
<pre className="mt-1.5 select-all rounded bg-background px-2 py-1.5 font-mono text-xs break-all">{newTokenValue}</pre>
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground">This won't be shown again.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
value={newTokenName}
|
||||||
|
onChange={(e) => setNewTokenName(e.target.value)}
|
||||||
|
placeholder="Token name..."
|
||||||
|
className="h-7 flex-1 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:border-ring"
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateToken(); }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateToken}
|
||||||
|
disabled={!newTokenName.trim()}
|
||||||
|
className="h-7 rounded-md bg-primary px-2.5 text-[11px] font-semibold text-primary-foreground hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tokenError && <p className="text-xs text-destructive">{tokenError}</p>}
|
||||||
|
{tokens.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{tokens.map((t) => (
|
||||||
|
<div key={t.id} className="flex items-center justify-between rounded-md border border-border/30 px-2.5 py-1.5">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium">{t.name}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
Created {new Date(t.created_at).toLocaleDateString()}
|
||||||
|
{t.last_used_at && ` · Last used ${new Date(t.last_used_at).toLocaleDateString()}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRevoke(t.id)}
|
||||||
|
className="text-[10px] text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">No API tokens yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<SidebarNavItem
|
||||||
|
href="/login"
|
||||||
|
icon={UserIcon}
|
||||||
|
label="Sign in"
|
||||||
|
active={pathname === "/login"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className={cn("flex", collapsed ? "justify-center" : "px-1")}>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NotificationBell({ collapsed, setCommandOpen }: { collapsed: boolean; setCommandOpen: (v: boolean) => void }) {
|
||||||
|
const [unread, setUnread] = useState(0);
|
||||||
|
const [notifs, setNotifs] = useState<Notification[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
const load = async () => {
|
||||||
|
const [countRes, notifRes] = await Promise.all([getUnreadCount(), getNotifications()]);
|
||||||
|
if (countRes.data) setUnread(countRes.data.count);
|
||||||
|
if (notifRes.data) setNotifs(notifRes.data);
|
||||||
|
};
|
||||||
|
void load();
|
||||||
|
// Poll every 30s
|
||||||
|
const interval = setInterval(() => { void load(); }, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const handleMarkRead = async (id: string) => {
|
||||||
|
await markNotificationRead(id);
|
||||||
|
setUnread((c) => Math.max(0, c - 1));
|
||||||
|
setNotifs((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAll = async () => {
|
||||||
|
await markAllNotificationsRead();
|
||||||
|
setUnread(0);
|
||||||
|
setNotifs((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="relative flex h-7 w-7 items-center justify-center rounded text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<BellIcon className="h-4 w-4" />
|
||||||
|
{unread > 0 && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-bold text-destructive-foreground">
|
||||||
|
{unread > 99 ? '99+' : unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-30" onClick={() => setOpen(false)} />
|
||||||
|
<div className="absolute right-0 top-full z-40 mt-1 w-80 rounded-lg border border-border bg-popover shadow-lg">
|
||||||
|
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2">
|
||||||
|
<span className="text-xs font-semibold text-foreground">Notifications</span>
|
||||||
|
{unread > 0 && (
|
||||||
|
<button onClick={handleMarkAll} className="text-[10px] text-muted-foreground hover:text-foreground">
|
||||||
|
Mark all read
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-80 overflow-auto">
|
||||||
|
{notifs.length === 0 ? (
|
||||||
|
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||||
|
No notifications yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
notifs.slice(0, 20).map((n) => (
|
||||||
|
<button
|
||||||
|
key={n.id}
|
||||||
|
onClick={() => {
|
||||||
|
handleMarkRead(n.id);
|
||||||
|
if (n.ticket_id) window.location.href = `/tickets/${n.ticket_id}`;
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"w-full border-b border-border/30 px-3 py-2.5 text-left transition-colors hover:bg-accent/30",
|
||||||
|
!n.read && "bg-primary/5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className={cn(
|
||||||
|
"mt-0.5 h-2 w-2 shrink-0 rounded-full",
|
||||||
|
n.read ? "bg-border" : "bg-primary"
|
||||||
|
)} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-medium text-foreground">{n.title}</p>
|
||||||
|
{n.body && (
|
||||||
|
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{n.body}</p>
|
||||||
|
)}
|
||||||
|
{n.ticket_id && (
|
||||||
|
<p className="mt-0.5 text-[10px] text-muted-foreground/60">
|
||||||
|
{formatTicketId(n.ticket_id)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<button
|
||||||
|
onClick={() => setCommandOpen(true)}
|
||||||
|
className="flex h-6 items-center gap-1 rounded border border-sidebar-border/50 px-1.5 text-[10px] text-sidebar-foreground/40 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground/60"
|
||||||
|
aria-label="Open command palette"
|
||||||
|
>
|
||||||
|
<CommandIcon className="h-3 w-3" />K
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
const [commandOpen, setCommandOpen] = useState(false);
|
const [commandOpen, setCommandOpen] = useState(false);
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
@@ -384,15 +630,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
<span className="text-sm font-semibold text-sidebar-foreground tracking-tight">Tessera</span>
|
<span className="text-sm font-semibold text-sidebar-foreground tracking-tight">Tessera</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
{!sidebarCollapsed && (
|
<NotificationBell collapsed={sidebarCollapsed} setCommandOpen={setCommandOpen} />
|
||||||
<button
|
|
||||||
onClick={() => setCommandOpen(true)}
|
|
||||||
className="flex h-6 items-center gap-1 rounded border border-sidebar-border/50 px-1.5 text-[10px] text-sidebar-foreground/40 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground/60"
|
|
||||||
aria-label="Open command palette"
|
|
||||||
>
|
|
||||||
<CommandIcon className="h-3 w-3" />K
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
|
|||||||
210
web/src/components/layout-builder.tsx
Normal file
210
web/src/components/layout-builder.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { GripVerticalIcon, XIcon, ArrowDownIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export interface LayoutField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleEntry {
|
||||||
|
key: string;
|
||||||
|
under: string; // which row1 column this subtitle field sits under
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayoutBuilderProps {
|
||||||
|
fields: LayoutField[];
|
||||||
|
row1: LayoutField[];
|
||||||
|
row2: SubtitleEntry[];
|
||||||
|
onChange: (row1: LayoutField[], row2: SubtitleEntry[]) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayoutBuilder({ fields: allFields, row1, row2, onChange, onClose }: LayoutBuilderProps) {
|
||||||
|
const [dragKey, setDragKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((e: React.DragEvent, key: string) => {
|
||||||
|
setDragKey(key);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
e.dataTransfer.setData("text/plain", key);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
setDragKey(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const makeRow1Drop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const key = e.dataTransfer.getData("text/plain");
|
||||||
|
if (!key) return;
|
||||||
|
// Remove from row2 if present
|
||||||
|
const newRow2 = row2.filter((e) => e.key !== key);
|
||||||
|
const field = allFields.find((f) => f.key === key);
|
||||||
|
if (!field) return;
|
||||||
|
// Insert into row1 via drop position
|
||||||
|
const container = e.currentTarget;
|
||||||
|
const children = Array.from(container.children).filter((c) => (c as HTMLElement).dataset?.chipkey);
|
||||||
|
const mouseX = e.clientX;
|
||||||
|
let idx = children.length;
|
||||||
|
for (let i = 0; i < children.length; i++) {
|
||||||
|
const rect = (children[i] as HTMLElement).getBoundingClientRect();
|
||||||
|
if (mouseX < rect.left + rect.width / 2) { idx = i; break; }
|
||||||
|
}
|
||||||
|
const newRow1 = [...row1.filter((f) => f.key !== key)];
|
||||||
|
newRow1.splice(idx, 0, field);
|
||||||
|
onChange(newRow1, newRow2);
|
||||||
|
}, [allFields, row1, row2, onChange]);
|
||||||
|
|
||||||
|
const makeSubtitleDrop = useCallback((underCol: string) => {
|
||||||
|
return (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const key = e.dataTransfer.getData("text/plain");
|
||||||
|
if (!key || key === "subject") return;
|
||||||
|
// Remove from row1 if present
|
||||||
|
const newRow1 = row1.filter((f) => f.key !== key);
|
||||||
|
// Remove from row2 if present
|
||||||
|
const newRow2 = row2.filter((e) => e.key !== key);
|
||||||
|
// Add to row2 under this column
|
||||||
|
newRow2.push({ key, under: underCol });
|
||||||
|
onChange(newRow1, newRow2);
|
||||||
|
};
|
||||||
|
}, [row1, row2, onChange]);
|
||||||
|
|
||||||
|
const makePaletteDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const key = e.dataTransfer.getData("text/plain");
|
||||||
|
if (!key) return;
|
||||||
|
onChange(
|
||||||
|
row1.filter((f) => f.key !== key),
|
||||||
|
row2.filter((e) => e.key !== key),
|
||||||
|
);
|
||||||
|
}, [row1, row2, onChange]);
|
||||||
|
|
||||||
|
const renderChip = (label: string, key: string) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
data-chipkey={key}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, key)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
className="flex cursor-grab items-center gap-1 rounded border border-border/50 bg-card px-2 py-1 text-xs text-foreground shadow-sm transition-colors hover:border-primary/30 active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<GripVerticalIcon className="h-3 w-3 text-muted-foreground/50" />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fields not in row1 or row2
|
||||||
|
const usedKeys = new Set([...row1.map((f) => f.key), ...row2.map((e) => e.key)]);
|
||||||
|
const palette = allFields.filter((f) => !usedKeys.has(f.key) && f.key !== "subject");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-[9998]" onClick={onClose} />
|
||||||
|
<div className="fixed left-1/2 top-1/2 z-[9999] w-[560px] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-popover shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-border/50 px-4 py-2.5">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">Layout builder</h3>
|
||||||
|
<button onClick={onClose} className="rounded text-muted-foreground hover:text-foreground">
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
{/* Row 1 */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 text-[10px] font-semibold uppercase text-muted-foreground/60">Main row</div>
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={makeRow1Drop}
|
||||||
|
className="flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border border-dashed border-border/50 p-2 transition-colors"
|
||||||
|
>
|
||||||
|
{row1.length === 0 ? (
|
||||||
|
<span className="text-xs text-muted-foreground/50">Drop fields here</span>
|
||||||
|
) : (
|
||||||
|
row1.map((f) => renderChip(f.label, f.key))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2 — subtitle fields under specific columns */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 text-[10px] font-semibold uppercase text-muted-foreground/60">Subtitle (drop under a column)</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{row1.map((col) => {
|
||||||
|
const entries = row2.filter((e) => e.under === col.key);
|
||||||
|
return (
|
||||||
|
<div key={col.key} className="flex items-start gap-2">
|
||||||
|
<div className="mt-1.5 w-20 shrink-0 text-right text-[10px] font-medium text-muted-foreground truncate">
|
||||||
|
{col.label}
|
||||||
|
<ArrowDownIcon className="inline-block ml-0.5 h-2.5 w-2.5" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={makeSubtitleDrop(col.key)}
|
||||||
|
className="flex min-h-7 flex-1 flex-wrap items-center gap-1 rounded border border-dashed border-border/50 px-2 py-1 transition-colors"
|
||||||
|
>
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<span className="text-[10px] text-muted-foreground/40">drop here</span>
|
||||||
|
) : (
|
||||||
|
entries.map((e) => {
|
||||||
|
const field = allFields.find((f) => f.key === e.key);
|
||||||
|
return renderChip(field?.label ?? e.key, e.key);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Orphans: subtitle fields under columns not in row1 */}
|
||||||
|
{(() => {
|
||||||
|
const orphanEntries = row2.filter((e) => !row1.some((c) => c.key === e.under));
|
||||||
|
if (orphanEntries.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="mt-1.5 w-20 shrink-0 text-right text-[10px] font-medium text-muted-foreground truncate">
|
||||||
|
subject
|
||||||
|
<ArrowDownIcon className="inline-block ml-0.5 h-2.5 w-2.5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex min-h-7 flex-1 flex-wrap items-center gap-1 rounded border border-dashed border-border/50 px-2 py-1">
|
||||||
|
{orphanEntries.map((e) => {
|
||||||
|
const field = allFields.find((f) => f.key === e.key);
|
||||||
|
return renderChip(field?.label ?? e.key, e.key);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Palette */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 text-[10px] font-semibold uppercase text-muted-foreground/60">Available</div>
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={makePaletteDrop}
|
||||||
|
className="flex min-h-9 flex-wrap gap-1.5 rounded-md border border-dashed border-border/30 p-2 transition-colors"
|
||||||
|
>
|
||||||
|
{palette.length === 0 ? (
|
||||||
|
<span className="text-xs text-muted-foreground/50">All fields are placed</span>
|
||||||
|
) : (
|
||||||
|
palette.map((f) => renderChip(f.label, f.key))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end border-t border-border/50 px-4 py-2.5">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-7 rounded-md bg-primary px-3 text-[11px] font-semibold text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
395
web/src/components/scrip-wizard.tsx
Normal file
395
web/src/components/scrip-wizard.tsx
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { XIcon, ArrowLeftIcon, ArrowRightIcon, CheckIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Queue, CustomField, Template } from "@/lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
error?: string | null;
|
||||||
|
onCreate: (data: {
|
||||||
|
name: string; condition_type: string; condition_config: Record<string, unknown>;
|
||||||
|
action_type: string; action_config: Record<string, unknown>;
|
||||||
|
template_id: string | null; queue_id: string | null;
|
||||||
|
stage: string; sort_order: number;
|
||||||
|
}) => Promise<void>;
|
||||||
|
queues: Queue[];
|
||||||
|
customFields: CustomField[];
|
||||||
|
templates: Template[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONDITIONS = [
|
||||||
|
{ type: "OnCreate", icon: "➕", label: "Ticket created", desc: "When a new ticket is created" },
|
||||||
|
{ type: "OnStatusChange", icon: "🔄", label: "Status changes", desc: "When ticket status changes" },
|
||||||
|
{ type: "OnResolve", icon: "✅", label: "Ticket resolved", desc: "When a ticket is resolved" },
|
||||||
|
{ type: "OnCustomFieldChange", icon: "📝", label: "Custom field changes", desc: "When a field value changes" },
|
||||||
|
{ type: "OnLinkCreate", icon: "🔗", label: "Ticket linked", desc: "When linked to another ticket" },
|
||||||
|
{ type: "OnOverdue", icon: "⏰", label: "Ticket overdue", desc: "When a date field passes due" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ACTIONS = [
|
||||||
|
{ type: "SendEmail", icon: "📧", label: "Send email", desc: "Send a templated email notification" },
|
||||||
|
{ type: "SetCustomField", icon: "🏷️", label: "Set custom field", desc: "Update a field on the ticket" },
|
||||||
|
{ type: "Webhook", icon: "🌐", label: "Call webhook", desc: "POST to an external URL" },
|
||||||
|
{ type: "FetchMetadata", icon: "📡", label: "Fetch metadata", desc: "Pull data from an API" },
|
||||||
|
{ type: "RunScript", icon: "⚡", label: "Run script", desc: "Execute custom JavaScript" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ScripWizard({ open, onClose, onCreate, queues, customFields, templates, error: externalError }: Props) {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Trigger
|
||||||
|
const [conditionType, setConditionType] = useState("OnCreate");
|
||||||
|
const [fromStatus, setFromStatus] = useState("");
|
||||||
|
const [toStatus, setToStatus] = useState("");
|
||||||
|
const [conditionFieldKey, setConditionFieldKey] = useState("");
|
||||||
|
|
||||||
|
// Action
|
||||||
|
const [actionType, setActionType] = useState("SendEmail");
|
||||||
|
const [emailTo, setEmailTo] = useState("requestor");
|
||||||
|
const [emailRecipients, setEmailRecipients] = useState("");
|
||||||
|
const [emailSubject, setEmailSubject] = useState("");
|
||||||
|
const [emailBody, setEmailBody] = useState("");
|
||||||
|
const [templateId, setTemplateId] = useState("");
|
||||||
|
const [fieldKey, setFieldKey] = useState("");
|
||||||
|
const [fieldValue, setFieldValue] = useState("");
|
||||||
|
const [webhookUrl, setWebhookUrl] = useState("");
|
||||||
|
|
||||||
|
// Scope
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [queueId, setQueueId] = useState("");
|
||||||
|
const [stage, setStage] = useState("TransactionCreate");
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
let conditionConfig: Record<string, unknown> = {};
|
||||||
|
if (conditionType === "OnStatusChange" || conditionType === "OnResolve") {
|
||||||
|
if (fromStatus) conditionConfig.from_status = fromStatus;
|
||||||
|
if (toStatus) conditionConfig.to_status = toStatus;
|
||||||
|
} else if (conditionType === "OnOverdue") {
|
||||||
|
if (conditionFieldKey) conditionConfig.field_key = conditionFieldKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
let actionConfig: Record<string, unknown> = {};
|
||||||
|
if (actionType === "SendEmail") {
|
||||||
|
const sources = emailTo === "requestor" ? ["requestor"] : emailTo === "owner" ? ["owner"] : [];
|
||||||
|
actionConfig = {
|
||||||
|
recipients: emailRecipients ? emailRecipients.split(",").map((s) => s.trim()).filter(Boolean) : [],
|
||||||
|
recipient_sources: sources,
|
||||||
|
subject: emailSubject || "",
|
||||||
|
body: emailBody || "",
|
||||||
|
};
|
||||||
|
} else if (actionType === "SetCustomField") {
|
||||||
|
actionConfig = { field_key: fieldKey || "", value: fieldValue || "" };
|
||||||
|
} else if (actionType === "Webhook") {
|
||||||
|
actionConfig = { url: webhookUrl || "", method: "POST" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await onCreate({
|
||||||
|
name: name || `Scrip: ${conditionType} → ${actionType}`,
|
||||||
|
condition_type: conditionType,
|
||||||
|
condition_config: conditionConfig,
|
||||||
|
action_type: actionType,
|
||||||
|
action_config: actionConfig,
|
||||||
|
template_id: templateId || null,
|
||||||
|
queue_id: queueId || null,
|
||||||
|
stage,
|
||||||
|
sort_order: 0,
|
||||||
|
});
|
||||||
|
setSaving(false);
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setStep(1); setConditionType("OnCreate"); setFromStatus(""); setToStatus(""); setConditionFieldKey("");
|
||||||
|
setActionType("SendEmail"); setEmailTo("requestor"); setEmailRecipients(""); setEmailSubject("");
|
||||||
|
setEmailBody(""); setTemplateId(""); setFieldKey(""); setFieldValue(""); setWebhookUrl("");
|
||||||
|
setName(""); setQueueId(""); setStage("TransactionCreate");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const stepLabels = ["Trigger", "Action", "Configure", "Review"];
|
||||||
|
const selectedQueue = queueId ? queues.find((q) => q.id === queueId) : null;
|
||||||
|
const dateFields = customFields.filter((cf) => cf.field_type === "date" || cf.field_type === "datetime");
|
||||||
|
const emailTemplates = templates.filter((t) => !t.queue_id || t.queue_id === queueId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => { onClose(); reset(); }} />
|
||||||
|
<div className="fixed left-1/2 top-1/2 z-50 w-[600px] max-h-[80vh] -translate-x-1/2 -translate-y-1/2 overflow-auto rounded-lg border border-border bg-popover shadow-xl">
|
||||||
|
{/* Header with steps */}
|
||||||
|
<div className="border-b border-border/50 px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold text-foreground">New automation</h2>
|
||||||
|
<button onClick={() => { onClose(); reset(); }} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
{stepLabels.map((label, i) => (
|
||||||
|
<div key={label} className="flex items-center gap-2">
|
||||||
|
<div className={cn(
|
||||||
|
"flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-bold",
|
||||||
|
step > i + 1 ? "bg-primary text-primary-foreground" :
|
||||||
|
step === i + 1 ? "bg-primary text-primary-foreground ring-2 ring-primary/30" :
|
||||||
|
"bg-muted text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{step > i + 1 ? <CheckIcon className="h-3 w-3" /> : i + 1}
|
||||||
|
</div>
|
||||||
|
<span className={cn("text-[11px] font-medium", step === i + 1 ? "text-foreground" : "text-muted-foreground")}>{label}</span>
|
||||||
|
{i < 3 && <div className="h-px w-6 bg-border" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Step 1: Trigger */}
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">When should this automation run?</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{CONDITIONS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setConditionType(c.type); setFromStatus(""); setToStatus(""); setConditionFieldKey(""); }}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border p-3 text-left transition-colors",
|
||||||
|
conditionType === c.type ? "border-primary/50 bg-primary/5 ring-1 ring-primary/30" : "border-border/50 hover:border-primary/30 hover:bg-accent/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-lg">{c.icon}</span>
|
||||||
|
<div className="mt-1 text-sm font-semibold">{c.label}</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground">{c.desc}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(conditionType === "OnStatusChange" || conditionType === "OnResolve") && (
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-[10px]">From status</Label>
|
||||||
|
<Select value={fromStatus || "_any"} onValueChange={(v) => setFromStatus((v === "_any" || !v) ? "" : v)}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue placeholder="Any status" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_any">Any status</SelectItem>
|
||||||
|
{["new","open","in_progress","resolved","closed"].map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-[10px]">To status</Label>
|
||||||
|
<Select value={toStatus || "_any"} onValueChange={(v) => setToStatus((v === "_any" || !v) ? "" : v)}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue placeholder={conditionType === "OnResolve" ? "Any resolved" : "Any status"} /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_any">{conditionType === "OnResolve" ? "Any resolved" : "Any status"}</SelectItem>
|
||||||
|
{["new","open","in_progress","resolved","closed"].map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{conditionType === "OnOverdue" && dateFields.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">Date field to check</Label>
|
||||||
|
<Select value={conditionFieldKey} onValueChange={(v) => setConditionFieldKey(v || "")}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue placeholder="Pick a date field..." /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{dateFields.map((cf) => <SelectItem key={cf.id} value={cf.key}>{cf.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Action */}
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">What should happen when triggered?</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{ACTIONS.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActionType(a.type)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border p-3 text-left transition-colors",
|
||||||
|
actionType === a.type ? "border-primary/50 bg-primary/5 ring-1 ring-primary/30" : "border-border/50 hover:border-primary/30 hover:bg-accent/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-lg">{a.icon}</span>
|
||||||
|
<div className="mt-1 text-sm font-semibold">{a.label}</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground">{a.desc}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Configure */}
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">Configure the details.</p>
|
||||||
|
|
||||||
|
{actionType === "SendEmail" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label>Recipients</Label>
|
||||||
|
<Select value={emailTo} onValueChange={(v) => setEmailTo(v ?? "requestor")}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="requestor">Ticket requestor (creator)</SelectItem>
|
||||||
|
<SelectItem value="owner">Ticket owner</SelectItem>
|
||||||
|
<SelectItem value="manual">Custom recipients</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{emailTo === "manual" && (
|
||||||
|
<div>
|
||||||
|
<Label>Email addresses (comma-separated)</Label>
|
||||||
|
<Input placeholder="user@example.com, other@example.com" value={emailRecipients} onChange={(e) => setEmailRecipients(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Label>Subject</Label>
|
||||||
|
<Input placeholder="Ticket #{{ticket.id}}: {{ticket.subject}}" value={emailSubject} onChange={(e) => setEmailSubject(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Body</Label>
|
||||||
|
<Textarea rows={3} placeholder="The ticket has been updated..." value={emailBody} onChange={(e) => setEmailBody(e.target.value)} />
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground">Variables: {"{{ticket.id}} {{ticket.subject}} {{ticket.status}} {{queue.name}} {{transaction.old_value}} {{transaction.new_value}}"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Template (optional)</Label>
|
||||||
|
<Select value={templateId} onValueChange={(v) => setTemplateId(v || "")}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue placeholder="No template — use subject/body above" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">No template</SelectItem>
|
||||||
|
{emailTemplates.map((t) => <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actionType === "SetCustomField" && (() => {
|
||||||
|
const selectedField = customFields.find((cf) => cf.key === fieldKey);
|
||||||
|
const fieldOptions: string[] = Array.isArray(selectedField?.values) ? selectedField.values.map((v: any) => String(v)) : [];
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label>Field</Label>
|
||||||
|
<Select value={fieldKey} onValueChange={(v) => { setFieldKey((v && v !== "_any") ? v : ""); setFieldValue(""); }}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue placeholder="Pick a field" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{customFields.map((cf) => <SelectItem key={cf.id} value={cf.key}>{cf.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label>Value</Label>
|
||||||
|
{fieldOptions.length > 0 ? (
|
||||||
|
<Select value={fieldValue} onValueChange={(v) => setFieldValue(v || "")}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue placeholder="Pick a value" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{fieldOptions.map((opt) => <SelectItem key={opt} value={opt}>{opt}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input placeholder="Value to set" value={fieldValue} onChange={(e) => setFieldValue(e.target.value)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{actionType === "Webhook" && (
|
||||||
|
<div>
|
||||||
|
<Label>URL</Label>
|
||||||
|
<Input placeholder="https://hooks.slack.com/..." value={webhookUrl} onChange={(e) => setWebhookUrl(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border-t border-border/30 pt-3 space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label>Name (optional)</Label>
|
||||||
|
<Input placeholder="My automation" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label>Queue scope</Label>
|
||||||
|
<Select value={queueId} onValueChange={(v) => setQueueId(v || "")}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue placeholder="All queues" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">All queues (global)</SelectItem>
|
||||||
|
{queues.map((q) => <SelectItem key={q.id} value={q.id}>{q.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: Review */}
|
||||||
|
{step === 4 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4 text-sm space-y-2">
|
||||||
|
<div className="text-[10px] font-semibold uppercase text-muted-foreground/60">Summary</div>
|
||||||
|
<p><strong>When:</strong> {CONDITIONS.find((c) => c.type === conditionType)?.label}</p>
|
||||||
|
<p><strong>Then:</strong> {ACTIONS.find((a) => a.type === actionType)?.label}</p>
|
||||||
|
{queueId && <p><strong>Queue:</strong> {selectedQueue?.name}</p>}
|
||||||
|
<p><strong>Stage:</strong> {stage === "TransactionCreate" ? "Per transaction" : "After batch"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input placeholder="My automation" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{externalError && (
|
||||||
|
<div className="border-t border-destructive/20 bg-destructive/10 px-6 py-2 text-sm text-destructive">{externalError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-between border-t border-border/50 px-6 py-3">
|
||||||
|
<div>
|
||||||
|
{step > 1 && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setStep(step - 1)}>
|
||||||
|
<ArrowLeftIcon className="h-3.5 w-3.5 mr-1" /> Back
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{step < 4 ? (
|
||||||
|
<Button size="sm" onClick={() => setStep(step + 1)}>
|
||||||
|
Next <ArrowRightIcon className="h-3.5 w-3.5 ml-1" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" className="bg-primary" disabled={saving} onClick={handleCreate}>
|
||||||
|
<CheckIcon className="h-3.5 w-3.5 mr-1" />
|
||||||
|
{saving ? "Creating..." : "Create automation"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
web/src/components/searchable-select.tsx
Normal file
163
web/src/components/searchable-select.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { ChevronDownIcon, SearchIcon, XIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchableSelectProps {
|
||||||
|
options: SelectOption[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
allowClear?: boolean;
|
||||||
|
clearLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchableSelect({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "Select...",
|
||||||
|
searchPlaceholder = "Search...",
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
allowClear = true,
|
||||||
|
clearLabel = "None",
|
||||||
|
}: SearchableSelectProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [highlightIdx, setHighlightIdx] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const selected = options.find((o) => o.value === value);
|
||||||
|
|
||||||
|
const filtered = search.trim()
|
||||||
|
? options.filter((o) => o.label.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
: options;
|
||||||
|
|
||||||
|
// Reset highlight when search changes
|
||||||
|
useEffect(() => {
|
||||||
|
setHighlightIdx(0);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
// Focus search input when opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
|
setSearch("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const select = useCallback((optValue: string) => {
|
||||||
|
onChange(optValue);
|
||||||
|
setOpen(false);
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightIdx((prev) => Math.min(prev + 1, filtered.length - 1));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightIdx((prev) => Math.max(prev - 1, 0));
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (filtered[highlightIdx]) {
|
||||||
|
select(filtered[highlightIdx].value);
|
||||||
|
}
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={cn("relative", className)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => !disabled && setOpen(!open)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 w-full items-center justify-between gap-1 rounded-md border border-input bg-transparent px-2.5 text-sm outline-none transition-colors",
|
||||||
|
disabled ? "opacity-50 cursor-not-allowed" : "hover:border-ring/50 focus:border-ring",
|
||||||
|
!selected && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{selected?.label ?? placeholder}</span>
|
||||||
|
<ChevronDownIcon className={cn("h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-md border border-border bg-popover shadow-lg">
|
||||||
|
<div className="flex items-center gap-1.5 border-b border-border/50 px-2">
|
||||||
|
<SearchIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className="h-8 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button onClick={() => setSearch("")} className="shrink-0 text-muted-foreground hover:text-foreground">
|
||||||
|
<XIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-48 overflow-auto">
|
||||||
|
{allowClear && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => select("")}
|
||||||
|
className="flex w-full items-center gap-2 border-b border-border/30 px-2.5 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
{clearLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="px-2.5 py-3 text-center text-xs text-muted-foreground">
|
||||||
|
No results
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((opt, idx) => (
|
||||||
|
<button
|
||||||
|
key={opt.value || "__empty__"}
|
||||||
|
type="button"
|
||||||
|
onClick={() => select(opt.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center px-2.5 py-1.5 text-xs transition-colors",
|
||||||
|
idx === highlightIdx ? "bg-accent text-foreground" : "text-foreground hover:bg-accent/50",
|
||||||
|
opt.value === value && "font-semibold"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
web/src/components/widgets/trend-chart-widget.tsx
Normal file
43
web/src/components/widgets/trend-chart-widget.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { WidgetData } from "@/lib/types";
|
||||||
|
|
||||||
|
export function TrendChartWidget({ data }: { data: WidgetData }) {
|
||||||
|
const points = data.counts ?? {};
|
||||||
|
const entries = Object.entries(points).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
const maxVal = Math.max(1, ...Object.values(points));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col rounded-lg border border-border/50 bg-card p-3">
|
||||||
|
<div className="mb-1 text-[10px] font-semibold uppercase text-muted-foreground/60">
|
||||||
|
{data.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold text-foreground tabular-nums">{data.total}</div>
|
||||||
|
<div className="mt-2 flex flex-1 items-end gap-px">
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<span className="text-xs text-muted-foreground">No data</span>
|
||||||
|
) : (
|
||||||
|
entries.map(([label, count]) => {
|
||||||
|
const h = Math.max(4, (count / maxVal) * 100);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="flex flex-1 flex-col items-center justify-end"
|
||||||
|
title={`${label}: ${count}`}
|
||||||
|
>
|
||||||
|
<div className="text-[9px] tabular-nums text-muted-foreground">{count}</div>
|
||||||
|
<div
|
||||||
|
className="w-full min-w-[3px] rounded-t bg-primary/60"
|
||||||
|
style={{ height: `${h}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[9px] text-muted-foreground/50 text-right">
|
||||||
|
{entries.length > 0 && `${entries[0]?.[0]} — ${entries[entries.length - 1]?.[0]}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,15 +17,30 @@ import type {
|
|||||||
QueueCustomField,
|
QueueCustomField,
|
||||||
PreviewResult,
|
PreviewResult,
|
||||||
UpdateResult,
|
UpdateResult,
|
||||||
|
Attachment,
|
||||||
|
AttachmentUploadResult,
|
||||||
|
TicketLink,
|
||||||
|
LoginResult,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const BASE_URL = "/api";
|
const BASE_URL = "/api";
|
||||||
|
|
||||||
async function request<T>(url: string, options?: RequestInit): Promise<{ data: T | null; error: string | null }> {
|
async function request<T>(url: string, options?: RequestInit): Promise<{ data: T | null; error: string | null }> {
|
||||||
try {
|
try {
|
||||||
|
const token = typeof window !== "undefined" ? localStorage.getItem("tessera_token") : null;
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
// Merge with options headers if any
|
||||||
|
const opts = { ...options };
|
||||||
|
if (opts.headers) {
|
||||||
|
Object.assign(headers, opts.headers as Record<string, string>);
|
||||||
|
delete opts.headers;
|
||||||
|
}
|
||||||
const res = await fetch(`${BASE_URL}${url}`, {
|
const res = await fetch(`${BASE_URL}${url}`, {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers,
|
||||||
...options,
|
...opts,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
@@ -45,6 +60,9 @@ export async function getTickets(params?: {
|
|||||||
owner_id?: string;
|
owner_id?: string;
|
||||||
team_id?: string;
|
team_id?: string;
|
||||||
custom_fields?: Record<string, string>;
|
custom_fields?: Record<string, string>;
|
||||||
|
subject?: string;
|
||||||
|
created?: string;
|
||||||
|
updated?: string;
|
||||||
}): Promise<{ data: Ticket[] | null; error: string | null }> {
|
}): Promise<{ data: Ticket[] | null; error: string | null }> {
|
||||||
const sp = new URLSearchParams();
|
const sp = new URLSearchParams();
|
||||||
if (params?.queue_id) sp.set("queue_id", params.queue_id);
|
if (params?.queue_id) sp.set("queue_id", params.queue_id);
|
||||||
@@ -52,6 +70,9 @@ export async function getTickets(params?: {
|
|||||||
if (params?.q) sp.set("q", params.q);
|
if (params?.q) sp.set("q", params.q);
|
||||||
if (params?.owner_id) sp.set("owner_id", params.owner_id);
|
if (params?.owner_id) sp.set("owner_id", params.owner_id);
|
||||||
if (params?.team_id) sp.set("team_id", params.team_id);
|
if (params?.team_id) sp.set("team_id", params.team_id);
|
||||||
|
if (params?.subject) sp.set("subject", params.subject);
|
||||||
|
if (params?.created) sp.set("created", params.created);
|
||||||
|
if (params?.updated) sp.set("updated", params.updated);
|
||||||
if (params?.custom_fields) {
|
if (params?.custom_fields) {
|
||||||
for (const [fieldId, value] of Object.entries(params.custom_fields)) {
|
for (const [fieldId, value] of Object.entries(params.custom_fields)) {
|
||||||
if (value) sp.set(`cf.${fieldId}`, value);
|
if (value) sp.set(`cf.${fieldId}`, value);
|
||||||
@@ -86,10 +107,80 @@ export async function getTicketTransactions(id: number): Promise<{ data: Transac
|
|||||||
return request<Transaction[]>(`/tickets/${id}/transactions`);
|
return request<Transaction[]>(`/tickets/${id}/transactions`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendComment(id: number, data: { body: string; internal?: boolean }): Promise<{ data: Transaction | null; error: string | null }> {
|
export async function sendComment(id: number, data: { body: string; internal?: boolean; attachment_ids?: string[]; time_worked_minutes?: number }): Promise<{ data: Transaction | null; error: string | null }> {
|
||||||
return request<Transaction>(`/tickets/${id}/comment`, { method: "POST", body: JSON.stringify(data) });
|
return request<Transaction>(`/tickets/${id}/comment`, { method: "POST", body: JSON.stringify(data) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function batchUpdateTickets(data: {
|
||||||
|
ticket_ids: number[];
|
||||||
|
status?: string;
|
||||||
|
owner_id?: string | null;
|
||||||
|
team_id?: string | null;
|
||||||
|
}): Promise<{ data: { results: Array<{ id: number; ok: boolean; error?: string }> } | null; error: string | null }> {
|
||||||
|
return request<{ results: Array<{ id: number; ok: boolean; error?: string }> }>("/tickets/batch", { method: "POST", body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergeTickets(sourceId: number, targetTicketId: number): Promise<{ data: { ok: boolean; target_id: number } | null; error: string | null }> {
|
||||||
|
return request<{ ok: boolean; target_id: number }>(`/tickets/${sourceId}/merge`, { method: "POST", body: JSON.stringify({ target_ticket_id: targetTicketId }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
ticket_id: number | null;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
body: string | null;
|
||||||
|
read: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNotifications(): Promise<{ data: Notification[] | null; error: string | null }> {
|
||||||
|
return request<Notification[]>("/notifications");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUnreadCount(): Promise<{ data: { count: number } | null; error: string | null }> {
|
||||||
|
return request<{ count: number }>("/notifications/unread-count");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markNotificationRead(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||||
|
return request<{ ok: boolean }>(`/notifications/${id}/read`, { method: "PATCH" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllNotificationsRead(): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||||
|
return request<{ ok: boolean }>("/notifications/read-all", { method: "PATCH" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Tokens
|
||||||
|
|
||||||
|
export interface ApiToken {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiTokenCreated {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
token: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getApiTokens(): Promise<{ data: ApiToken[] | null; error: string | null }> {
|
||||||
|
return request<ApiToken[]>("/auth/tokens");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createApiToken(name: string): Promise<{ data: ApiTokenCreated | null; error: string | null }> {
|
||||||
|
return request<ApiTokenCreated>("/auth/tokens", { method: "POST", body: JSON.stringify({ name }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeApiToken(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||||
|
return request<{ ok: boolean }>(`/auth/tokens/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
export async function getQueues(): Promise<{ data: Queue[] | null; error: string | null }> {
|
export async function getQueues(): Promise<{ data: Queue[] | null; error: string | null }> {
|
||||||
return request<Queue[]>("/queues");
|
return request<Queue[]>("/queues");
|
||||||
}
|
}
|
||||||
@@ -101,6 +192,8 @@ export async function getUsers(): Promise<{ data: User[] | null; error: string |
|
|||||||
export async function createUser(data: {
|
export async function createUser(data: {
|
||||||
username: string;
|
username: string;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
|
role?: string;
|
||||||
|
password?: string | null;
|
||||||
}): Promise<{ data: User | null; error: string | null }> {
|
}): Promise<{ data: User | null; error: string | null }> {
|
||||||
return request<User>("/users", { method: "POST", body: JSON.stringify(data) });
|
return request<User>("/users", { method: "POST", body: JSON.stringify(data) });
|
||||||
}
|
}
|
||||||
@@ -108,6 +201,8 @@ export async function createUser(data: {
|
|||||||
export async function updateUser(id: string, data: {
|
export async function updateUser(id: string, data: {
|
||||||
username?: string;
|
username?: string;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
|
role?: string;
|
||||||
|
password?: string | null;
|
||||||
}): Promise<{ data: User | null; error: string | null }> {
|
}): Promise<{ data: User | null; error: string | null }> {
|
||||||
return request<User>(`/users/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
return request<User>(`/users/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||||
}
|
}
|
||||||
@@ -268,7 +363,7 @@ export async function createView(data: {
|
|||||||
name: string;
|
name: string;
|
||||||
filters: { field: string; operator: string; value: string }[];
|
filters: { field: string; operator: string; value: string }[];
|
||||||
sort_key?: string;
|
sort_key?: string;
|
||||||
columns?: { key: string; label: string; width: number; visible: boolean }[];
|
columns?: { key: string; label: string; width: number; display: string }[];
|
||||||
is_public?: boolean;
|
is_public?: boolean;
|
||||||
}): Promise<{ data: SavedView | null; error: string | null }> {
|
}): Promise<{ data: SavedView | null; error: string | null }> {
|
||||||
return request<SavedView>("/views", { method: "POST", body: JSON.stringify(data) });
|
return request<SavedView>("/views", { method: "POST", body: JSON.stringify(data) });
|
||||||
@@ -373,3 +468,207 @@ export async function addTeamMember(teamId: string, userId: string): Promise<{ d
|
|||||||
export async function removeTeamMember(teamId: string, userId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
export async function removeTeamMember(teamId: string, userId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||||
return request<{ ok: boolean }>(`/teams/${teamId}/members/${userId}`, { method: "DELETE" });
|
return request<{ ok: boolean }>(`/teams/${teamId}/members/${userId}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadAttachments(
|
||||||
|
ticketId: number,
|
||||||
|
files: File[],
|
||||||
|
): Promise<{ data: { attachments: AttachmentUploadResult[] } | null; error: string | null }> {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const file of files) {
|
||||||
|
formData.append("files", file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = typeof window !== "undefined" ? localStorage.getItem("tessera_token") : null;
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/tickets/${ticketId}/attachments`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
return { data: null, error: body.error || body.message || `HTTP ${res.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return { data, error: null };
|
||||||
|
} catch (err) {
|
||||||
|
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAttachmentUrl(attachmentId: string): string {
|
||||||
|
return `/api/attachments/${attachmentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTicketAttachments(
|
||||||
|
ticketId: number,
|
||||||
|
): Promise<{ data: Attachment[] | null; error: string | null }> {
|
||||||
|
return request<Attachment[]>(`/tickets/${ticketId}/attachments`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTicketLinks(
|
||||||
|
ticketId: number,
|
||||||
|
): Promise<{ data: TicketLink[] | null; error: string | null }> {
|
||||||
|
return request<TicketLink[]>(`/tickets/${ticketId}/links`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTicketLink(
|
||||||
|
ticketId: number,
|
||||||
|
data: { target_ticket_id: number; link_type: string },
|
||||||
|
): Promise<{ data: TicketLink | null; error: string | null }> {
|
||||||
|
return request<TicketLink>(`/tickets/${ticketId}/links`, { method: "POST", body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTicketLink(
|
||||||
|
ticketId: number,
|
||||||
|
linkId: string,
|
||||||
|
): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||||
|
return request<{ ok: boolean }>(`/tickets/${ticketId}/links/${linkId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue Permissions (admin)
|
||||||
|
|
||||||
|
export interface QueuePermission {
|
||||||
|
id: string;
|
||||||
|
queue_id: string;
|
||||||
|
team_id: string;
|
||||||
|
right_name: string;
|
||||||
|
team_name?: string;
|
||||||
|
queue_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQueuePermissions(): Promise<{ data: QueuePermission[] | null; error: string | null }> {
|
||||||
|
return request<QueuePermission[]>("/queue-permissions");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTeamsAndQueues(): Promise<{ data: { teams: Team[]; queues: Queue[] } | null; error: string | null }> {
|
||||||
|
return request<{ teams: Team[]; queues: Queue[] }>("/queue-permissions/teams-and-queues");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function grantQueuePermission(
|
||||||
|
queue_id: string,
|
||||||
|
team_id: string,
|
||||||
|
right_name: string,
|
||||||
|
): Promise<{ data: QueuePermission | null; error: string | null }> {
|
||||||
|
return request<QueuePermission>("/queue-permissions", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ queue_id, team_id, right_name }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeQueuePermission(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||||
|
return request<{ ok: boolean }>(`/queue-permissions/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Permissions (admin)
|
||||||
|
|
||||||
|
export interface UserPermission {
|
||||||
|
id: string;
|
||||||
|
queue_id: string;
|
||||||
|
user_id: string;
|
||||||
|
right_name: string;
|
||||||
|
username?: string;
|
||||||
|
queue_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserPermissions(): Promise<{ data: UserPermission[] | null; error: string | null }> {
|
||||||
|
return request<UserPermission[]>("/user-permissions");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function grantUserPermission(
|
||||||
|
queue_id: string,
|
||||||
|
user_id: string,
|
||||||
|
right_name: string,
|
||||||
|
): Promise<{ data: UserPermission | null; error: string | null }> {
|
||||||
|
return request<UserPermission>("/user-permissions", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ queue_id, user_id, right_name }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeUserPermission(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||||
|
return request<{ ok: boolean }>(`/user-permissions/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
|
||||||
|
function getStoredToken(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return localStorage.getItem("tessera_token");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredToken(token: string | null) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem("tessera_token", token);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("tessera_token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ data: LoginResult | null; error: string | null }> {
|
||||||
|
const result = await request<LoginResult>("/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
if (result.data?.token) {
|
||||||
|
setStoredToken(result.data.token);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout() {
|
||||||
|
setStoredToken(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMe(): Promise<{ data: User | null; error: string | null }> {
|
||||||
|
const token = getStoredToken();
|
||||||
|
if (!token) return { data: null, error: "Not authenticated" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/auth/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
setStoredToken(null);
|
||||||
|
return { data: null, error: "Session expired" };
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return { data, error: null };
|
||||||
|
} catch (err) {
|
||||||
|
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch wrapper that includes the auth token.
|
||||||
|
*/
|
||||||
|
async function authRequest<T>(url: string, options?: RequestInit): Promise<{ data: T | null; error: string | null }> {
|
||||||
|
const token = getStoredToken();
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api${url}`, { ...options, headers: { ...headers, ...(options?.headers as Record<string, string> ?? {}) } });
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
setStoredToken(null);
|
||||||
|
}
|
||||||
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
return { data: null, error: body.error || body.message || `HTTP ${res.status}` };
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return { data, error: null };
|
||||||
|
} catch (err) {
|
||||||
|
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
61
web/src/lib/auth-context.tsx
Normal file
61
web/src/lib/auth-context.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
|
||||||
|
import { login as apiLogin, logout as apiLogout, getMe } from "./api";
|
||||||
|
import type { User, LoginResult } from "./types";
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
loading: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<string | null>;
|
||||||
|
logout: () => void;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState>({
|
||||||
|
user: null,
|
||||||
|
loading: true,
|
||||||
|
login: async () => null,
|
||||||
|
logout: () => {},
|
||||||
|
isAdmin: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Check existing session on mount
|
||||||
|
useEffect(() => {
|
||||||
|
void Promise.resolve().then(async () => {
|
||||||
|
const { data } = await getMe();
|
||||||
|
if (data) {
|
||||||
|
setUser(data);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(async (username: string, password: string): Promise<string | null> => {
|
||||||
|
const { data, error } = await apiLogin(username, password);
|
||||||
|
if (error || !data) {
|
||||||
|
return error || "Login failed";
|
||||||
|
}
|
||||||
|
setUser(data.user);
|
||||||
|
return null; // null = success
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
apiLogout();
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, login, logout, isAdmin: user?.role === "admin" }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export interface Ticket {
|
|||||||
started_at: string | null;
|
started_at: string | null;
|
||||||
resolved_at: string | null;
|
resolved_at: string | null;
|
||||||
custom_fields?: CustomFieldValue[];
|
custom_fields?: CustomFieldValue[];
|
||||||
|
blocked_by?: Array<{ id: number; subject: string; status: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Queue {
|
export interface Queue {
|
||||||
@@ -25,9 +26,15 @@ export interface User {
|
|||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
role: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LoginResult {
|
||||||
|
token: string;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Transaction {
|
export interface Transaction {
|
||||||
id: string;
|
id: string;
|
||||||
ticket_id: number;
|
ticket_id: number;
|
||||||
@@ -36,8 +43,10 @@ export interface Transaction {
|
|||||||
old_value: string | null;
|
old_value: string | null;
|
||||||
new_value: string | null;
|
new_value: string | null;
|
||||||
data: unknown;
|
data: unknown;
|
||||||
|
time_worked_minutes: number;
|
||||||
creator_id: string;
|
creator_id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
attachments?: Attachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Scrip {
|
export interface Scrip {
|
||||||
@@ -53,6 +62,7 @@ export interface Scrip {
|
|||||||
stage: string;
|
stage: string;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
applicable_trans_types: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,4 +207,50 @@ export interface WidgetData {
|
|||||||
counts?: Record<string, number>;
|
counts?: Record<string, number>;
|
||||||
groups?: Record<string, number>;
|
groups?: Record<string, number>;
|
||||||
group_by?: string;
|
group_by?: string;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Attachment {
|
||||||
|
id: string;
|
||||||
|
transaction_id: string | null;
|
||||||
|
filename: string;
|
||||||
|
mime_type: string;
|
||||||
|
size_bytes: number;
|
||||||
|
storage_path: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachmentUploadResult {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
mime_type: string;
|
||||||
|
size_bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketLink {
|
||||||
|
id: string;
|
||||||
|
ticket_id: number;
|
||||||
|
target_ticket_id: number;
|
||||||
|
link_type: string;
|
||||||
|
creator_id: string;
|
||||||
|
created_at: string;
|
||||||
|
target_ticket?: { id: number; subject: string; status: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
ticket_id: number | null;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
body: string | null;
|
||||||
|
read: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiToken {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user