feat: add teams/groups with dashboard scoping
Schema: - teams table (name unique, description) - team_members table (team_id, user_id, unique constraint) - team_id column on dashboards API: - GET/POST/PATCH/DELETE /teams - POST /teams/:id/members (add user) - DELETE /teams/:id/members/:userId (remove user) - dashboards support team_id on create/update Frontend: - Teams tab in admin: CRUD + member management with add/remove - Sidebar: dashboards filtered to user's teams (unassigned dashboards visible to all) - Compact dashboard picker dropdown in sidebar Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { pgTable, uuid, text, jsonb, integer, boolean, timestamp, unique, index } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { relations, sql } from 'drizzle-orm';
|
||||
|
||||
export const users = pgTable('users', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
@@ -124,10 +124,35 @@ export const views = pgTable('views', {
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
export const teams = pgTable('teams', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull().unique(),
|
||||
description: text('description'),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
export const teamsRelations = relations(teams, ({ many }) => ({
|
||||
members: many(teamMembers),
|
||||
}));
|
||||
|
||||
export const teamMembers = pgTable('team_members', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
team_id: uuid('team_id').notNull().references(() => teams.id, { onDelete: 'cascade' }),
|
||||
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
}, (table) => ({
|
||||
uniqueMember: unique('team_members_team_id_user_id_unique').on(table.team_id, table.user_id),
|
||||
}));
|
||||
|
||||
export const teamMembersRelations = relations(teamMembers, ({ one }) => ({
|
||||
team: one(teams, { fields: [teamMembers.team_id], references: [teams.id] }),
|
||||
user: one(users, { fields: [teamMembers.user_id], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const dashboards = pgTable('dashboards', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
team_id: uuid('team_id').references(() => teams.id, { onDelete: 'set null' }),
|
||||
layout: jsonb('layout').default('[]'),
|
||||
is_default: boolean('is_default').default(false),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createUsersRouter } from './routes/users.ts';
|
||||
import { createTemplatesRouter } from './routes/templates.ts';
|
||||
import { createViewsRouter } from './routes/views.ts';
|
||||
import { createDashboardsRouter } from './routes/dashboards.ts';
|
||||
import { createTeamsRouter } from './routes/teams.ts';
|
||||
|
||||
let db: Db | null = null;
|
||||
|
||||
@@ -39,6 +40,7 @@ app.route('/users', createUsersRouter(getDb()));
|
||||
app.route('/templates', createTemplatesRouter(getDb()));
|
||||
app.route('/views', createViewsRouter(getDb()));
|
||||
app.route('/dashboards', createDashboardsRouter(getDb()));
|
||||
app.route('/teams', createTeamsRouter(getDb()));
|
||||
|
||||
export default app;
|
||||
export { app };
|
||||
|
||||
@@ -42,6 +42,7 @@ export function createDashboardsRouter(db: Db): Hono {
|
||||
const [dashboard] = await db.insert(dashboards).values({
|
||||
name,
|
||||
description: body.description ?? null,
|
||||
team_id: body.team_id || null,
|
||||
layout: body.layout ?? [],
|
||||
is_default: body.is_default ?? false,
|
||||
}).returning();
|
||||
@@ -87,9 +88,9 @@ export function createDashboardsRouter(db: Db): Hono {
|
||||
if (body.name !== undefined) updateData.name = String(body.name).trim();
|
||||
if (body.description !== undefined) updateData.description = body.description ?? null;
|
||||
if (body.layout !== undefined) updateData.layout = body.layout;
|
||||
if (body.team_id !== undefined) updateData.team_id = body.team_id || null;
|
||||
if (body.is_default !== undefined) {
|
||||
updateData.is_default = body.is_default;
|
||||
// If setting this as default, unset others
|
||||
if (body.is_default) {
|
||||
await db.update(dashboards)
|
||||
.set({ is_default: false })
|
||||
|
||||
98
src/routes/teams.ts
Normal file
98
src/routes/teams.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { and, asc, eq, inArray } from 'drizzle-orm';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { teams, teamMembers, users, dashboards } from '../db/schema.ts';
|
||||
|
||||
export function createTeamsRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
// GET /teams — list all with member details
|
||||
router.get('/', async (c) => {
|
||||
const allTeams = await db.query.teams.findMany({
|
||||
orderBy: asc(teams.name),
|
||||
});
|
||||
|
||||
const result = await Promise.all(allTeams.map(async (team) => {
|
||||
const members = await db.query.teamMembers.findMany({
|
||||
where: eq(teamMembers.team_id, team.id),
|
||||
});
|
||||
const memberUsers = members.length > 0
|
||||
? await db.query.users.findMany({
|
||||
where: (table, { inArray }) => inArray(table.id, members.map((m) => m.user_id)),
|
||||
})
|
||||
: [];
|
||||
return { ...team, members: memberUsers };
|
||||
}));
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// POST /teams
|
||||
router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const name = String(body.name ?? '').trim();
|
||||
if (!name) throw new HTTPException(400, { message: 'name is required' });
|
||||
|
||||
const [team] = await db.insert(teams).values({
|
||||
name,
|
||||
description: body.description ?? null,
|
||||
}).returning();
|
||||
if (!team) throw new HTTPException(500, { message: 'Failed to create team' });
|
||||
return c.json(team, 201);
|
||||
});
|
||||
|
||||
// PATCH /teams/:id
|
||||
router.patch('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json();
|
||||
const existing = await db.query.teams.findFirst({ where: eq(teams.id, id) });
|
||||
if (!existing) throw new HTTPException(404, { message: 'Team not found' });
|
||||
|
||||
const updateData: Partial<typeof teams.$inferInsert> = {};
|
||||
if (body.name !== undefined) updateData.name = String(body.name).trim();
|
||||
if (body.description !== undefined) updateData.description = body.description ?? null;
|
||||
|
||||
const [updated] = await db.update(teams).set(updateData).where(eq(teams.id, id)).returning();
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
// DELETE /teams/:id
|
||||
router.delete('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const existing = await db.query.teams.findFirst({ where: eq(teams.id, id) });
|
||||
if (!existing) throw new HTTPException(404, { message: 'Team not found' });
|
||||
await db.delete(teams).where(eq(teams.id, id));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /teams/:id/members — add member
|
||||
router.post('/:id/members', async (c) => {
|
||||
const teamId = c.req.param('id');
|
||||
const body = await c.req.json();
|
||||
const userId = String(body.user_id ?? '').trim();
|
||||
if (!userId) throw new HTTPException(400, { message: 'user_id is required' });
|
||||
|
||||
const team = await db.query.teams.findFirst({ where: eq(teams.id, teamId) });
|
||||
if (!team) throw new HTTPException(404, { message: 'Team not found' });
|
||||
|
||||
const [member] = await db.insert(teamMembers).values({
|
||||
team_id: teamId,
|
||||
user_id: userId,
|
||||
}).returning();
|
||||
if (!member) throw new HTTPException(500, { message: 'Failed to add member' });
|
||||
return c.json(member, 201);
|
||||
});
|
||||
|
||||
// DELETE /teams/:id/members/:userId
|
||||
router.delete('/:id/members/:userId', async (c) => {
|
||||
const teamId = c.req.param('id');
|
||||
const userId = c.req.param('userId');
|
||||
await db.delete(teamMembers).where(
|
||||
and(eq(teamMembers.team_id, teamId), eq(teamMembers.user_id, userId))
|
||||
);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
Reference in New Issue
Block a user