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:
Gjermund Høsøien Wiggen
2026-06-09 13:32:39 +02:00
parent c79cd183d4
commit 3616046b78
11 changed files with 1713 additions and 25 deletions

View File

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

View File

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

View File

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