feat(den): add skill hubs and restore den-db migrations (#1285)

* feat(den-db): add skill hub schema and own migrations

* feat(den-api): add skill hub org routes

* fix(den-db): restore drizzle migration workflow

Move the Docker Den service onto den-api and repair den-db's Drizzle metadata so skill hub migrations generate incrementally from the package.

* refactor(den-db): drop legacy org table aliases

---------

Co-authored-by: src-opn <src-opn@users.noreply.github.com>
This commit is contained in:
Source Open
2026-04-01 15:23:39 -07:00
committed by GitHub
parent 4198f20e16
commit 4caf178048
26 changed files with 3699 additions and 35 deletions

View File

@@ -17,7 +17,7 @@ export const resolveMemberTeamsMiddleware: MiddlewareHandler<{
const memberTeams = await listTeamsForMember({
organizationId: context.organization.id,
userId: context.currentMember.userId,
memberId: context.currentMember.id,
})
c.set("memberTeams", memberTeams)

View File

@@ -17,6 +17,7 @@ type UserId = typeof AuthUserTable.$inferSelect.id
type SessionId = typeof AuthSessionTable.$inferSelect.id
type OrgId = typeof OrganizationTable.$inferSelect.id
type MemberRow = typeof MemberTable.$inferSelect
type MemberId = MemberRow["id"]
type InvitationRow = typeof InvitationTable.$inferSelect
export type InvitationStatus = "pending" | "accepted" | "canceled" | "expired"
@@ -61,14 +62,14 @@ export type OrganizationContext = {
updatedAt: Date
}
currentMember: {
id: string
id: MemberId
userId: UserId
role: string
createdAt: Date
isOwner: boolean
}
members: Array<{
id: string
id: MemberId
userId: UserId
role: string
createdAt: Date
@@ -295,14 +296,14 @@ async function acceptInvitation(invitation: InvitationRow, userId: UserId) {
const existingTeamMember = await db
.select({ id: TeamMemberTable.id })
.from(TeamMemberTable)
.where(and(eq(TeamMemberTable.teamId, invitation.teamId), eq(TeamMemberTable.userId, userId)))
.where(and(eq(TeamMemberTable.teamId, invitation.teamId), eq(TeamMemberTable.orgMembershipId, member.id)))
.limit(1)
if (!existingTeamMember[0]) {
await db.insert(TeamMemberTable).values({
id: createDenTypeId("teamMember"),
teamId: invitation.teamId,
userId,
orgMembershipId: member.id,
})
}
}
@@ -659,7 +660,7 @@ export async function getOrganizationContextForUser(input: {
export async function listTeamsForMember(input: {
organizationId: OrgId
userId: UserId
memberId: MemberRow["id"]
}) {
return db
.select({
@@ -671,7 +672,7 @@ export async function listTeamsForMember(input: {
})
.from(TeamMemberTable)
.innerJoin(TeamTable, eq(TeamMemberTable.teamId, TeamTable.id))
.where(and(eq(TeamTable.organizationId, input.organizationId), eq(TeamMemberTable.userId, input.userId)))
.where(and(eq(TeamTable.organizationId, input.organizationId), eq(TeamMemberTable.orgMembershipId, input.memberId)))
.orderBy(asc(TeamTable.createdAt))
}
@@ -699,7 +700,7 @@ export async function removeOrganizationMember(input: {
for (const team of teams) {
await tx
.delete(TeamMemberTable)
.where(and(eq(TeamMemberTable.teamId, team.id), eq(TeamMemberTable.userId, member.userId)))
.where(and(eq(TeamMemberTable.teamId, team.id), eq(TeamMemberTable.orgMembershipId, member.id)))
}
await tx.delete(MemberTable).where(eq(MemberTable.id, member.id))

View File

@@ -4,6 +4,7 @@ import { registerOrgCoreRoutes } from "./core.js"
import { registerOrgInvitationRoutes } from "./invitations.js"
import { registerOrgMemberRoutes } from "./members.js"
import { registerOrgRoleRoutes } from "./roles.js"
import { registerOrgSkillRoutes } from "./skills.js"
import { registerOrgTemplateRoutes } from "./templates.js"
export function registerOrgRoutes<T extends { Variables: OrgRouteVariables }>(app: Hono<T>) {
@@ -11,5 +12,6 @@ export function registerOrgRoutes<T extends { Variables: OrgRouteVariables }>(ap
registerOrgInvitationRoutes(app)
registerOrgMemberRoutes(app)
registerOrgRoleRoutes(app)
registerOrgSkillRoutes(app)
registerOrgTemplateRoutes(app)
}

View File

@@ -0,0 +1,875 @@
import { and, desc, eq, inArray, isNotNull, or } from "@openwork-ee/den-db/drizzle"
import {
AuthUserTable,
MemberTable,
SkillHubMemberTable,
SkillHubSkillTable,
SkillHubTable,
SkillTable,
TeamTable,
} from "@openwork-ee/den-db/schema"
import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid"
import type { Hono } from "hono"
import { z } from "zod"
import { db } from "../../db.js"
import {
jsonValidator,
paramValidator,
requireUserMiddleware,
resolveMemberTeamsMiddleware,
resolveOrganizationContextMiddleware,
} from "../../middleware/index.js"
import type { MemberTeamsContext } from "../../middleware/member-teams.js"
import type { OrgRouteVariables } from "./shared.js"
import { idParamSchema, memberHasRole, orgIdParamSchema } from "./shared.js"
const createSkillSchema = z.object({
skillText: z.string().trim().min(1),
shared: z.enum(["org", "public"]).nullable().optional(),
})
const createSkillHubSchema = z.object({
name: z.string().trim().min(1).max(255),
description: z.string().trim().max(65535).nullish().transform((value) => value || null),
})
const updateSkillHubSchema = z.object({
name: z.string().trim().min(1).max(255).optional(),
description: z.string().trim().max(65535).nullable().optional(),
}).superRefine((value, ctx) => {
if (value.name === undefined && value.description === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["name"],
message: "Provide at least one field to update.",
})
}
})
const addSkillToHubSchema = z.object({
skillId: z.string().trim().min(1),
})
const addSkillHubAccessSchema = z.object({
orgMembershipId: z.string().trim().min(1).optional(),
teamId: z.string().trim().min(1).optional(),
}).superRefine((value, ctx) => {
const count = Number(Boolean(value.orgMembershipId)) + Number(Boolean(value.teamId))
if (count !== 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["orgMembershipId"],
message: "Provide exactly one of orgMembershipId or teamId.",
})
}
})
type SkillId = typeof SkillTable.$inferSelect.id
type SkillHubId = typeof SkillHubTable.$inferSelect.id
type SkillHubMemberId = typeof SkillHubMemberTable.$inferSelect.id
type TeamId = typeof TeamTable.$inferSelect.id
type MemberId = typeof MemberTable.$inferSelect.id
type SkillRow = typeof SkillTable.$inferSelect
type SkillHubRow = typeof SkillHubTable.$inferSelect
const orgSkillHubParamsSchema = orgIdParamSchema.extend(idParamSchema("skillHubId").shape)
const orgSkillParamsSchema = orgIdParamSchema.extend(idParamSchema("skillId").shape)
const orgSkillHubSkillParamsSchema = orgSkillHubParamsSchema.extend(idParamSchema("skillId").shape)
const orgSkillHubAccessParamsSchema = orgSkillHubParamsSchema.extend(idParamSchema("accessId").shape)
function parseSkillId(value: string) {
return normalizeDenTypeId("skill", value)
}
function parseSkillHubId(value: string) {
return normalizeDenTypeId("skillHub", value)
}
function parseSkillHubMemberId(value: string) {
return normalizeDenTypeId("skillHubMember", value)
}
function parseMemberId(value: string) {
return normalizeDenTypeId("member", value)
}
function parseTeamId(value: string) {
return normalizeDenTypeId("team", value)
}
function parseSkillMetadata(skillText: string) {
const lines = skillText
.split(/\r?\n/g)
.map((line) => line.trim())
.filter(Boolean)
const cleanup = (value: string) => value
.replace(/^#{1,6}\s+/, "")
.replace(/^[-*+]\s+/, "")
.replace(/^title\s*:\s*/i, "")
.replace(/^description\s*:\s*/i, "")
.trim()
const title = cleanup(lines[0] ?? "") || "Untitled skill"
const description = lines.slice(1).map(cleanup).find(Boolean) ?? null
return {
title: title.slice(0, 255),
description: description ? description.slice(0, 65535) : null,
}
}
function isOrganizationAdmin(payload: { currentMember: { isOwner: boolean; role: string } }) {
return payload.currentMember.isOwner || memberHasRole(payload.currentMember.role, "admin")
}
function canManageSkill(payload: { currentMember: { id: MemberId; isOwner: boolean; role: string } }, skill: SkillRow) {
return isOrganizationAdmin(payload) || skill.createdByOrgMembershipId === payload.currentMember.id
}
function canManageHub(payload: { currentMember: { id: MemberId; isOwner: boolean; role: string } }, skillHub: SkillHubRow) {
return isOrganizationAdmin(payload) || skillHub.createdByOrgMembershipId === payload.currentMember.id
}
async function listAccessibleHubMemberships(input: {
organizationId: typeof SkillHubTable.$inferSelect.organizationId
currentMemberId: MemberId
memberTeams: Array<{ id: TeamId }>
}) {
const teamIds = input.memberTeams.map((team) => team.id)
const accessWhere = teamIds.length > 0
? and(
eq(SkillHubTable.organizationId, input.organizationId),
or(
eq(SkillHubMemberTable.orgMembershipId, input.currentMemberId),
inArray(SkillHubMemberTable.teamId, teamIds),
),
)
: and(
eq(SkillHubTable.organizationId, input.organizationId),
eq(SkillHubMemberTable.orgMembershipId, input.currentMemberId),
)
return db
.select({
id: SkillHubMemberTable.id,
skillHubId: SkillHubMemberTable.skillHubId,
orgMembershipId: SkillHubMemberTable.orgMembershipId,
teamId: SkillHubMemberTable.teamId,
createdAt: SkillHubMemberTable.createdAt,
})
.from(SkillHubMemberTable)
.innerJoin(SkillHubTable, eq(SkillHubMemberTable.skillHubId, SkillHubTable.id))
.where(accessWhere)
}
async function listAccessibleSkillIds(input: {
organizationId: typeof SkillHubTable.$inferSelect.organizationId
currentMemberId: MemberId
memberTeams: Array<{ id: TeamId }>
}) {
const memberships = await listAccessibleHubMemberships(input)
const hubIds = [...new Set(memberships.map((membership) => membership.skillHubId))]
if (hubIds.length === 0) {
return new Set<SkillId>()
}
const rows = await db
.select({ skillId: SkillHubSkillTable.skillId })
.from(SkillHubSkillTable)
.where(inArray(SkillHubSkillTable.skillHubId, hubIds))
return new Set(rows.map((row) => row.skillId))
}
function canViewSkill(input: {
currentMemberId: MemberId
skill: SkillRow
accessibleSkillIds: Set<SkillId>
}) {
return input.skill.createdByOrgMembershipId === input.currentMemberId
|| input.skill.shared !== null
|| input.accessibleSkillIds.has(input.skill.id)
}
export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables & Partial<MemberTeamsContext> }>(app: Hono<T>) {
app.post(
"/v1/orgs/:orgId/skills",
requireUserMiddleware,
paramValidator(orgIdParamSchema),
resolveOrganizationContextMiddleware,
jsonValidator(createSkillSchema),
async (c) => {
const payload = c.get("organizationContext")
const input = c.req.valid("json")
const now = new Date()
const skillId = createDenTypeId("skill")
const metadata = parseSkillMetadata(input.skillText)
await db.insert(SkillTable).values({
id: skillId,
organizationId: payload.organization.id,
createdByOrgMembershipId: payload.currentMember.id,
title: metadata.title,
description: metadata.description,
skillText: input.skillText,
shared: input.shared ?? null,
createdAt: now,
updatedAt: now,
})
return c.json({
skill: {
id: skillId,
organizationId: payload.organization.id,
createdByOrgMembershipId: payload.currentMember.id,
title: metadata.title,
description: metadata.description,
skillText: input.skillText,
shared: input.shared ?? null,
createdAt: now,
updatedAt: now,
},
}, 201)
},
)
app.get(
"/v1/orgs/:orgId/skills",
requireUserMiddleware,
paramValidator(orgIdParamSchema),
resolveOrganizationContextMiddleware,
resolveMemberTeamsMiddleware,
async (c) => {
const payload = c.get("organizationContext")
const memberTeams = c.get("memberTeams") ?? []
const accessibleSkillIds = await listAccessibleSkillIds({
organizationId: payload.organization.id,
currentMemberId: payload.currentMember.id,
memberTeams,
})
const skills = await db
.select()
.from(SkillTable)
.where(eq(SkillTable.organizationId, payload.organization.id))
.orderBy(desc(SkillTable.updatedAt))
return c.json({
skills: skills
.filter((skill) => canViewSkill({
currentMemberId: payload.currentMember.id,
skill,
accessibleSkillIds,
}))
.map((skill) => ({
...skill,
canManage: canManageSkill(payload, skill),
})),
})
},
)
app.delete(
"/v1/orgs/:orgId/skills/:skillId",
requireUserMiddleware,
paramValidator(orgSkillParamsSchema),
resolveOrganizationContextMiddleware,
async (c) => {
const payload = c.get("organizationContext")
const params = c.req.valid("param")
let skillId: SkillId
try {
skillId = parseSkillId(params.skillId)
} catch {
return c.json({ error: "skill_not_found" }, 404)
}
const skillRows = await db
.select()
.from(SkillTable)
.where(and(eq(SkillTable.id, skillId), eq(SkillTable.organizationId, payload.organization.id)))
.limit(1)
const skill = skillRows[0]
if (!skill) {
return c.json({ error: "skill_not_found" }, 404)
}
if (!canManageSkill(payload, skill)) {
return c.json({ error: "forbidden", message: "Only the skill creator or an org admin can delete skills." }, 403)
}
await db.transaction(async (tx) => {
await tx.delete(SkillHubSkillTable).where(eq(SkillHubSkillTable.skillId, skill.id))
await tx.delete(SkillTable).where(eq(SkillTable.id, skill.id))
})
return c.body(null, 204)
},
)
app.post(
"/v1/orgs/:orgId/skill-hubs",
requireUserMiddleware,
paramValidator(orgIdParamSchema),
resolveOrganizationContextMiddleware,
jsonValidator(createSkillHubSchema),
async (c) => {
const payload = c.get("organizationContext")
const input = c.req.valid("json")
const now = new Date()
const skillHubId = createDenTypeId("skillHub")
await db.transaction(async (tx) => {
await tx.insert(SkillHubTable).values({
id: skillHubId,
organizationId: payload.organization.id,
createdByOrgMembershipId: payload.currentMember.id,
name: input.name,
description: input.description,
createdAt: now,
updatedAt: now,
})
await tx.insert(SkillHubMemberTable).values({
id: createDenTypeId("skillHubMember"),
skillHubId,
orgMembershipId: payload.currentMember.id,
teamId: null,
createdAt: now,
})
})
return c.json({
skillHub: {
id: skillHubId,
organizationId: payload.organization.id,
createdByOrgMembershipId: payload.currentMember.id,
name: input.name,
description: input.description,
createdAt: now,
updatedAt: now,
},
}, 201)
},
)
app.get(
"/v1/orgs/:orgId/skill-hubs",
requireUserMiddleware,
paramValidator(orgIdParamSchema),
resolveOrganizationContextMiddleware,
resolveMemberTeamsMiddleware,
async (c) => {
const payload = c.get("organizationContext")
const memberTeams = c.get("memberTeams") ?? []
const accessibleMemberships = await listAccessibleHubMemberships({
organizationId: payload.organization.id,
currentMemberId: payload.currentMember.id,
memberTeams,
})
const skillHubIds = [...new Set(accessibleMemberships.map((membership) => membership.skillHubId))]
if (skillHubIds.length === 0) {
return c.json({ skillHubs: [] })
}
const skillHubs = await db
.select()
.from(SkillHubTable)
.where(and(eq(SkillHubTable.organizationId, payload.organization.id), inArray(SkillHubTable.id, skillHubIds)))
.orderBy(desc(SkillHubTable.updatedAt))
const skillLinks = await db
.select({ skillHubId: SkillHubSkillTable.skillHubId, skillId: SkillHubSkillTable.skillId })
.from(SkillHubSkillTable)
.where(inArray(SkillHubSkillTable.skillHubId, skillHubIds))
const skillIds = [...new Set(skillLinks.map((link) => link.skillId))]
const skills = skillIds.length === 0
? []
: await db
.select()
.from(SkillTable)
.where(and(eq(SkillTable.organizationId, payload.organization.id), inArray(SkillTable.id, skillIds)))
const memberAccessRows = await db
.select({
access: {
id: SkillHubMemberTable.id,
skillHubId: SkillHubMemberTable.skillHubId,
createdAt: SkillHubMemberTable.createdAt,
},
member: {
id: MemberTable.id,
role: MemberTable.role,
},
user: {
id: AuthUserTable.id,
name: AuthUserTable.name,
email: AuthUserTable.email,
image: AuthUserTable.image,
},
})
.from(SkillHubMemberTable)
.innerJoin(MemberTable, eq(SkillHubMemberTable.orgMembershipId, MemberTable.id))
.innerJoin(AuthUserTable, eq(MemberTable.userId, AuthUserTable.id))
.where(and(inArray(SkillHubMemberTable.skillHubId, skillHubIds), isNotNull(SkillHubMemberTable.orgMembershipId)))
const teamAccessRows = await db
.select({
access: {
id: SkillHubMemberTable.id,
skillHubId: SkillHubMemberTable.skillHubId,
createdAt: SkillHubMemberTable.createdAt,
},
team: {
id: TeamTable.id,
name: TeamTable.name,
createdAt: TeamTable.createdAt,
updatedAt: TeamTable.updatedAt,
},
})
.from(SkillHubMemberTable)
.innerJoin(TeamTable, eq(SkillHubMemberTable.teamId, TeamTable.id))
.where(and(inArray(SkillHubMemberTable.skillHubId, skillHubIds), isNotNull(SkillHubMemberTable.teamId)))
const skillsById = new Map(skills.map((skill) => [skill.id, skill]))
const skillsByHubId = new Map<SkillHubId, SkillRow[]>()
for (const link of skillLinks) {
const skill = skillsById.get(link.skillId)
if (!skill) {
continue
}
const existing = skillsByHubId.get(link.skillHubId) ?? []
existing.push(skill)
skillsByHubId.set(link.skillHubId, existing)
}
const memberAccessByHubId = new Map<SkillHubId, typeof memberAccessRows>()
for (const row of memberAccessRows) {
const existing = memberAccessByHubId.get(row.access.skillHubId) ?? []
existing.push(row)
memberAccessByHubId.set(row.access.skillHubId, existing)
}
const teamAccessByHubId = new Map<SkillHubId, typeof teamAccessRows>()
for (const row of teamAccessRows) {
const existing = teamAccessByHubId.get(row.access.skillHubId) ?? []
existing.push(row)
teamAccessByHubId.set(row.access.skillHubId, existing)
}
const accessibleViaByHubId = new Map<SkillHubId, { orgMembershipIds: MemberId[]; teamIds: TeamId[] }>()
for (const row of accessibleMemberships) {
const existing = accessibleViaByHubId.get(row.skillHubId) ?? { orgMembershipIds: [], teamIds: [] }
if (row.orgMembershipId && !existing.orgMembershipIds.includes(row.orgMembershipId)) {
existing.orgMembershipIds.push(row.orgMembershipId)
}
if (row.teamId && !existing.teamIds.includes(row.teamId)) {
existing.teamIds.push(row.teamId)
}
accessibleViaByHubId.set(row.skillHubId, existing)
}
return c.json({
skillHubs: skillHubs.map((skillHub) => ({
...skillHub,
canManage: canManageHub(payload, skillHub),
accessibleVia: accessibleViaByHubId.get(skillHub.id) ?? { orgMembershipIds: [], teamIds: [] },
skills: skillsByHubId.get(skillHub.id) ?? [],
access: {
members: (memberAccessByHubId.get(skillHub.id) ?? []).map((row) => ({
id: row.access.id,
orgMembershipId: row.member.id,
role: row.member.role,
user: row.user,
createdAt: row.access.createdAt,
})),
teams: (teamAccessByHubId.get(skillHub.id) ?? []).map((row) => ({
id: row.access.id,
teamId: row.team.id,
name: row.team.name,
createdAt: row.team.createdAt,
updatedAt: row.team.updatedAt,
})),
},
})),
})
},
)
app.patch(
"/v1/orgs/:orgId/skill-hubs/:skillHubId",
requireUserMiddleware,
paramValidator(orgSkillHubParamsSchema),
resolveOrganizationContextMiddleware,
jsonValidator(updateSkillHubSchema),
async (c) => {
const payload = c.get("organizationContext")
const params = c.req.valid("param")
const input = c.req.valid("json")
let skillHubId: SkillHubId
try {
skillHubId = parseSkillHubId(params.skillHubId)
} catch {
return c.json({ error: "skill_hub_not_found" }, 404)
}
const skillHubRows = await db
.select()
.from(SkillHubTable)
.where(and(eq(SkillHubTable.id, skillHubId), eq(SkillHubTable.organizationId, payload.organization.id)))
.limit(1)
const skillHub = skillHubRows[0]
if (!skillHub) {
return c.json({ error: "skill_hub_not_found" }, 404)
}
if (!canManageHub(payload, skillHub)) {
return c.json({ error: "forbidden", message: "Only the hub creator or an org admin can update hubs." }, 403)
}
const updatedAt = new Date()
const nextName = input.name ?? skillHub.name
const nextDescription = input.description === undefined ? skillHub.description : input.description
await db
.update(SkillHubTable)
.set({
name: nextName,
description: nextDescription,
updatedAt,
})
.where(eq(SkillHubTable.id, skillHub.id))
return c.json({
skillHub: {
...skillHub,
name: nextName,
description: nextDescription,
updatedAt,
},
})
},
)
app.delete(
"/v1/orgs/:orgId/skill-hubs/:skillHubId",
requireUserMiddleware,
paramValidator(orgSkillHubParamsSchema),
resolveOrganizationContextMiddleware,
async (c) => {
const payload = c.get("organizationContext")
const params = c.req.valid("param")
let skillHubId: SkillHubId
try {
skillHubId = parseSkillHubId(params.skillHubId)
} catch {
return c.json({ error: "skill_hub_not_found" }, 404)
}
const skillHubRows = await db
.select()
.from(SkillHubTable)
.where(and(eq(SkillHubTable.id, skillHubId), eq(SkillHubTable.organizationId, payload.organization.id)))
.limit(1)
const skillHub = skillHubRows[0]
if (!skillHub) {
return c.json({ error: "skill_hub_not_found" }, 404)
}
if (!canManageHub(payload, skillHub)) {
return c.json({ error: "forbidden", message: "Only the hub creator or an org admin can delete hubs." }, 403)
}
await db.transaction(async (tx) => {
await tx.delete(SkillHubMemberTable).where(eq(SkillHubMemberTable.skillHubId, skillHub.id))
await tx.delete(SkillHubSkillTable).where(eq(SkillHubSkillTable.skillHubId, skillHub.id))
await tx.delete(SkillHubTable).where(eq(SkillHubTable.id, skillHub.id))
})
return c.body(null, 204)
},
)
app.post(
"/v1/orgs/:orgId/skill-hubs/:skillHubId/skills",
requireUserMiddleware,
paramValidator(orgSkillHubParamsSchema),
resolveOrganizationContextMiddleware,
jsonValidator(addSkillToHubSchema),
async (c) => {
const payload = c.get("organizationContext")
const params = c.req.valid("param")
const input = c.req.valid("json")
let skillHubId: SkillHubId
let skillId: SkillId
try {
skillHubId = parseSkillHubId(params.skillHubId)
skillId = parseSkillId(input.skillId)
} catch {
return c.json({ error: "not_found" }, 404)
}
const skillHubRows = await db
.select()
.from(SkillHubTable)
.where(and(eq(SkillHubTable.id, skillHubId), eq(SkillHubTable.organizationId, payload.organization.id)))
.limit(1)
const skillHub = skillHubRows[0]
if (!skillHub) {
return c.json({ error: "skill_hub_not_found" }, 404)
}
if (!canManageHub(payload, skillHub)) {
return c.json({ error: "forbidden", message: "Only the hub creator or an org admin can manage hub skills." }, 403)
}
const skillRows = await db
.select()
.from(SkillTable)
.where(and(eq(SkillTable.id, skillId), eq(SkillTable.organizationId, payload.organization.id)))
.limit(1)
const skill = skillRows[0]
if (!skill) {
return c.json({ error: "skill_not_found" }, 404)
}
if (!canManageSkill(payload, skill) && skill.shared === null) {
return c.json({
error: "forbidden",
message: "Private skills can only be added to hubs by their creator or an org admin.",
}, 403)
}
const existing = await db
.select({ id: SkillHubSkillTable.id })
.from(SkillHubSkillTable)
.where(and(eq(SkillHubSkillTable.skillHubId, skillHubId), eq(SkillHubSkillTable.skillId, skill.id)))
.limit(1)
if (existing[0]) {
return c.json({ error: "skill_hub_skill_exists" }, 409)
}
await db.insert(SkillHubSkillTable).values({
id: createDenTypeId("skillHubSkill"),
skillHubId,
skillId: skill.id,
addedByOrgMembershipId: payload.currentMember.id,
createdAt: new Date(),
})
return c.json({ success: true }, 201)
},
)
app.delete(
"/v1/orgs/:orgId/skill-hubs/:skillHubId/skills/:skillId",
requireUserMiddleware,
paramValidator(orgSkillHubSkillParamsSchema),
resolveOrganizationContextMiddleware,
async (c) => {
const payload = c.get("organizationContext")
const params = c.req.valid("param")
let skillHubId: SkillHubId
let skillId: SkillId
try {
skillHubId = parseSkillHubId(params.skillHubId)
skillId = parseSkillId(params.skillId)
} catch {
return c.json({ error: "not_found" }, 404)
}
const skillHubRows = await db
.select()
.from(SkillHubTable)
.where(and(eq(SkillHubTable.id, skillHubId), eq(SkillHubTable.organizationId, payload.organization.id)))
.limit(1)
const skillHub = skillHubRows[0]
if (!skillHub) {
return c.json({ error: "skill_hub_not_found" }, 404)
}
if (!canManageHub(payload, skillHub)) {
return c.json({ error: "forbidden", message: "Only the hub creator or an org admin can manage hub skills." }, 403)
}
const existing = await db
.select({ id: SkillHubSkillTable.id })
.from(SkillHubSkillTable)
.where(and(eq(SkillHubSkillTable.skillHubId, skillHubId), eq(SkillHubSkillTable.skillId, skillId)))
.limit(1)
if (!existing[0]) {
return c.json({ error: "skill_hub_skill_not_found" }, 404)
}
await db
.delete(SkillHubSkillTable)
.where(and(eq(SkillHubSkillTable.skillHubId, skillHubId), eq(SkillHubSkillTable.skillId, skillId)))
return c.body(null, 204)
},
)
app.post(
"/v1/orgs/:orgId/skill-hubs/:skillHubId/access",
requireUserMiddleware,
paramValidator(orgSkillHubParamsSchema),
resolveOrganizationContextMiddleware,
jsonValidator(addSkillHubAccessSchema),
async (c) => {
const payload = c.get("organizationContext")
const params = c.req.valid("param")
const input = c.req.valid("json")
let skillHubId: SkillHubId
let orgMembershipId: MemberId | null = null
let teamId: TeamId | null = null
try {
skillHubId = parseSkillHubId(params.skillHubId)
orgMembershipId = input.orgMembershipId ? parseMemberId(input.orgMembershipId) : null
teamId = input.teamId ? parseTeamId(input.teamId) : null
} catch {
return c.json({ error: "access_target_not_found" }, 404)
}
const skillHubRows = await db
.select()
.from(SkillHubTable)
.where(and(eq(SkillHubTable.id, skillHubId), eq(SkillHubTable.organizationId, payload.organization.id)))
.limit(1)
const skillHub = skillHubRows[0]
if (!skillHub) {
return c.json({ error: "skill_hub_not_found" }, 404)
}
if (!canManageHub(payload, skillHub)) {
return c.json({ error: "forbidden", message: "Only the hub creator or an org admin can manage access." }, 403)
}
if (orgMembershipId) {
const memberRows = await db
.select({ id: MemberTable.id })
.from(MemberTable)
.where(and(eq(MemberTable.id, orgMembershipId), eq(MemberTable.organizationId, payload.organization.id)))
.limit(1)
if (!memberRows[0]) {
return c.json({ error: "member_not_found" }, 404)
}
}
if (teamId) {
const teamRows = await db
.select({ id: TeamTable.id })
.from(TeamTable)
.where(and(eq(TeamTable.id, teamId), eq(TeamTable.organizationId, payload.organization.id)))
.limit(1)
if (!teamRows[0]) {
return c.json({ error: "team_not_found" }, 404)
}
}
const existing = await db
.select({ id: SkillHubMemberTable.id })
.from(SkillHubMemberTable)
.where(
orgMembershipId
? and(eq(SkillHubMemberTable.skillHubId, skillHubId), eq(SkillHubMemberTable.orgMembershipId, orgMembershipId))
: and(eq(SkillHubMemberTable.skillHubId, skillHubId), eq(SkillHubMemberTable.teamId, teamId as TeamId)),
)
.limit(1)
if (existing[0]) {
return c.json({ error: "skill_hub_access_exists" }, 409)
}
const accessId = createDenTypeId("skillHubMember")
const createdAt = new Date()
await db.insert(SkillHubMemberTable).values({
id: accessId,
skillHubId,
orgMembershipId,
teamId,
createdAt,
})
return c.json({
access: {
id: accessId,
skillHubId,
orgMembershipId,
teamId,
createdAt,
},
}, 201)
},
)
app.delete(
"/v1/orgs/:orgId/skill-hubs/:skillHubId/access/:accessId",
requireUserMiddleware,
paramValidator(orgSkillHubAccessParamsSchema),
resolveOrganizationContextMiddleware,
async (c) => {
const payload = c.get("organizationContext")
const params = c.req.valid("param")
let skillHubId: SkillHubId
let accessId: SkillHubMemberId
try {
skillHubId = parseSkillHubId(params.skillHubId)
accessId = parseSkillHubMemberId(params.accessId)
} catch {
return c.json({ error: "not_found" }, 404)
}
const skillHubRows = await db
.select()
.from(SkillHubTable)
.where(and(eq(SkillHubTable.id, skillHubId), eq(SkillHubTable.organizationId, payload.organization.id)))
.limit(1)
const skillHub = skillHubRows[0]
if (!skillHub) {
return c.json({ error: "skill_hub_not_found" }, 404)
}
if (!canManageHub(payload, skillHub)) {
return c.json({ error: "forbidden", message: "Only the hub creator or an org admin can manage access." }, 403)
}
const accessRows = await db
.select()
.from(SkillHubMemberTable)
.where(and(eq(SkillHubMemberTable.id, accessId), eq(SkillHubMemberTable.skillHubId, skillHubId)))
.limit(1)
const access = accessRows[0]
if (!access) {
return c.json({ error: "skill_hub_access_not_found" }, 404)
}
await db.delete(SkillHubMemberTable).where(eq(SkillHubMemberTable.id, access.id))
return c.body(null, 204)
},
)
}

View File

@@ -4,7 +4,7 @@ import {
AuditEventTable,
AuthUserTable,
DaytonaSandboxTable,
OrgMembershipTable,
MemberTable,
WorkerBundleTable,
WorkerInstanceTable,
WorkerTable,
@@ -63,7 +63,7 @@ export type WorkerRouteVariables = AuthContextVariables & Partial<UserOrganizati
type WorkerRow = typeof WorkerTable.$inferSelect
type WorkerInstanceRow = typeof WorkerInstanceTable.$inferSelect
export type WorkerId = WorkerRow["id"]
type OrgId = typeof OrgMembershipTable.$inferSelect.organizationId
type OrgId = typeof MemberTable.$inferSelect.organizationId
type UserId = typeof AuthUserTable.$inferSelect.id
export const token = () => randomBytes(32).toString("hex")

View File

@@ -0,0 +1,36 @@
# den-db
`@openwork-ee/den-db` owns the Den database schema and migration history.
## Canonical workflow
- Keep schema changes in `src/schema/**`.
- Keep generated SQL migrations in `drizzle/`.
- Always generate new migrations with Drizzle from this package.
- Do not create migrations from `den-api`, `den-controller`, or other apps.
## Commands
Generate a migration after editing the schema:
```bash
pnpm --dir ee/packages/den-db db:generate
```
Apply schema directly to a development database:
```bash
pnpm --dir ee/packages/den-db db:push
```
Run Drizzle migrations against a configured database:
```bash
pnpm --dir ee/packages/den-db db:migrate
```
## Notes
- `db:generate` is the default path for new migration files.
- `drizzle/meta/` must stay in sync with the SQL migration history so future generation stays incremental.
- Only repair `drizzle/meta/` manually when recovering broken Drizzle history.

View File

@@ -1,13 +1,13 @@
import "./src/load-env.ts"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { defineConfig } from "drizzle-kit"
import { parseMySqlConnectionConfig } from "./src/mysql-config.ts"
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const databaseUrl = process.env.DATABASE_URL?.trim()
function isGenerateCommand() {
return process.argv.some((arg) => arg === "generate")
}
function resolveDrizzleDbCredentials() {
if (databaseUrl) {
return parseMySqlConnectionConfig(databaseUrl)
@@ -18,6 +18,14 @@ function resolveDrizzleDbCredentials() {
const password = process.env.DATABASE_PASSWORD ?? ""
if (!host || !user) {
if (isGenerateCommand()) {
return {
host: "127.0.0.1",
user: "root",
password: "",
}
}
throw new Error("Provide DATABASE_URL for mysql or DATABASE_HOST/DATABASE_USERNAME/DATABASE_PASSWORD for planetscale")
}
@@ -30,7 +38,7 @@ function resolveDrizzleDbCredentials() {
export default defineConfig({
dialect: "mysql",
schema: path.join(currentDir, "src", "schema.ts"),
out: path.join(currentDir, "..", "..", "apps", "den-controller", "drizzle"),
schema: "./src/schema.ts",
out: "./drizzle",
dbCredentials: resolveDrizzleDbCredentials(),
})

View File

@@ -0,0 +1,13 @@
CREATE TABLE `desktop_handoff_grant` (
`id` varchar(64) NOT NULL,
`user_id` varchar(64) NOT NULL,
`session_token` text NOT NULL,
`expires_at` timestamp(3) NOT NULL,
`consumed_at` timestamp(3),
`created_at` timestamp(3) NOT NULL DEFAULT (now(3)),
CONSTRAINT `desktop_handoff_grant_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `desktop_handoff_grant_user_id` ON `desktop_handoff_grant` (`user_id`);
--> statement-breakpoint
CREATE INDEX `desktop_handoff_grant_expires_at` ON `desktop_handoff_grant` (`expires_at`);

View File

@@ -0,0 +1,12 @@
ALTER TABLE `worker`
ADD `last_heartbeat_at` timestamp(3);
--> statement-breakpoint
ALTER TABLE `worker`
ADD `last_active_at` timestamp(3);
--> statement-breakpoint
CREATE INDEX `worker_last_heartbeat_at` ON `worker` (`last_heartbeat_at`);
--> statement-breakpoint
CREATE INDEX `worker_last_active_at` ON `worker` (`last_active_at`);
--> statement-breakpoint
ALTER TABLE `worker_token`
MODIFY COLUMN `scope` enum('client','host','activity') NOT NULL;

View File

@@ -0,0 +1,8 @@
CREATE TABLE `rate_limit` (
`id` varchar(255) NOT NULL,
`key` varchar(512) NOT NULL,
`count` int NOT NULL DEFAULT 0,
`last_request` bigint NOT NULL,
CONSTRAINT `rate_limit_id` PRIMARY KEY(`id`),
CONSTRAINT `rate_limit_key` UNIQUE(`key`)
);

View File

@@ -0,0 +1,105 @@
-- Manual SQL variant for MySQL/Vitess consoles.
-- - No `--> statement-breakpoint` markers
-- - No PREPARE/EXECUTE dynamic SQL
-- - Avoids `ADD COLUMN IF NOT EXISTS` (not supported in some Vitess setups)
-- Run these only if the columns are missing.
-- Check first:
-- SELECT column_name FROM information_schema.columns
-- WHERE table_schema = DATABASE() AND table_name = 'session'
-- AND column_name IN ('active_organization_id', 'active_team_id');
ALTER TABLE `session`
ADD COLUMN `active_organization_id` varchar(64) NULL;
ALTER TABLE `session`
ADD COLUMN `active_team_id` varchar(64) NULL;
CREATE TABLE IF NOT EXISTS `organization` (
`id` varchar(64) NOT NULL,
`name` varchar(255) NOT NULL,
`slug` varchar(255) NOT NULL,
`logo` varchar(2048),
`metadata` text,
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT `organization_id` PRIMARY KEY(`id`),
CONSTRAINT `organization_slug` UNIQUE(`slug`)
);
CREATE TABLE IF NOT EXISTS `member` (
`id` varchar(64) NOT NULL,
`organization_id` varchar(64) NOT NULL,
`user_id` varchar(64) NOT NULL,
`role` varchar(255) NOT NULL DEFAULT 'member',
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT `member_id` PRIMARY KEY(`id`),
CONSTRAINT `member_organization_user` UNIQUE(`organization_id`, `user_id`),
KEY `member_organization_id` (`organization_id`),
KEY `member_user_id` (`user_id`)
);
CREATE TABLE IF NOT EXISTS `invitation` (
`id` varchar(64) NOT NULL,
`organization_id` varchar(64) NOT NULL,
`email` varchar(255) NOT NULL,
`role` varchar(255) NOT NULL,
`status` varchar(32) NOT NULL DEFAULT 'pending',
`team_id` varchar(64) DEFAULT NULL,
`inviter_id` varchar(64) NOT NULL,
`expires_at` timestamp(3) NOT NULL,
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT `invitation_id` PRIMARY KEY(`id`),
KEY `invitation_organization_id` (`organization_id`),
KEY `invitation_email` (`email`),
KEY `invitation_status` (`status`),
KEY `invitation_team_id` (`team_id`)
);
CREATE TABLE IF NOT EXISTS `team` (
`id` varchar(64) NOT NULL,
`name` varchar(255) NOT NULL,
`organization_id` varchar(64) NOT NULL,
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT `team_id` PRIMARY KEY(`id`),
CONSTRAINT `team_organization_name` UNIQUE(`organization_id`, `name`),
KEY `team_organization_id` (`organization_id`)
);
CREATE TABLE IF NOT EXISTS `team_member` (
`id` varchar(64) NOT NULL,
`team_id` varchar(64) NOT NULL,
`user_id` varchar(64) NOT NULL,
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT `team_member_id` PRIMARY KEY(`id`),
CONSTRAINT `team_member_team_user` UNIQUE(`team_id`, `user_id`),
KEY `team_member_team_id` (`team_id`),
KEY `team_member_user_id` (`user_id`)
);
CREATE TABLE IF NOT EXISTS `organization_role` (
`id` varchar(64) NOT NULL,
`organization_id` varchar(64) NOT NULL,
`role` varchar(255) NOT NULL,
`permission` text NOT NULL,
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT `organization_role_id` PRIMARY KEY(`id`),
CONSTRAINT `organization_role_name` UNIQUE(`organization_id`, `role`),
KEY `organization_role_organization_id` (`organization_id`)
);
-- Optional legacy backfill. Run only if these legacy tables exist:
-- org
-- org_membership
--
-- INSERT INTO `organization` (`id`, `name`, `slug`, `logo`, `metadata`, `created_at`, `updated_at`)
-- SELECT `id`, `name`, `slug`, NULL, NULL, `created_at`, `updated_at`
-- FROM `org`
-- WHERE `id` NOT IN (SELECT `id` FROM `organization`);
--
-- INSERT INTO `member` (`id`, `organization_id`, `user_id`, `role`, `created_at`)
-- SELECT `id`, `org_id`, `user_id`, `role`, `created_at`
-- FROM `org_membership`
-- WHERE `id` NOT IN (SELECT `id` FROM `member`);

View File

@@ -0,0 +1,152 @@
SET @has_active_organization_id := (
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'session'
AND column_name = 'active_organization_id'
);
--> statement-breakpoint
SET @add_active_organization_id_sql := IF(
@has_active_organization_id = 0,
'ALTER TABLE `session` ADD COLUMN `active_organization_id` varchar(64) NULL',
'SELECT 1'
);
--> statement-breakpoint
PREPARE add_active_organization_id_stmt FROM @add_active_organization_id_sql;
--> statement-breakpoint
EXECUTE add_active_organization_id_stmt;
--> statement-breakpoint
DEALLOCATE PREPARE add_active_organization_id_stmt;
--> statement-breakpoint
SET @has_active_team_id := (
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'session'
AND column_name = 'active_team_id'
);
--> statement-breakpoint
SET @add_active_team_id_sql := IF(
@has_active_team_id = 0,
'ALTER TABLE `session` ADD COLUMN `active_team_id` varchar(64) NULL',
'SELECT 1'
);
--> statement-breakpoint
PREPARE add_active_team_id_stmt FROM @add_active_team_id_sql;
--> statement-breakpoint
EXECUTE add_active_team_id_stmt;
--> statement-breakpoint
DEALLOCATE PREPARE add_active_team_id_stmt;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `organization` (
`id` varchar(64) NOT NULL,
`name` varchar(255) NOT NULL,
`slug` varchar(255) NOT NULL,
`logo` varchar(2048),
`metadata` text,
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT `organization_id` PRIMARY KEY(`id`),
CONSTRAINT `organization_slug` UNIQUE(`slug`)
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `member` (
`id` varchar(64) NOT NULL,
`organization_id` varchar(64) NOT NULL,
`user_id` varchar(64) NOT NULL,
`role` varchar(255) NOT NULL DEFAULT 'member',
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT `member_id` PRIMARY KEY(`id`),
CONSTRAINT `member_organization_user` UNIQUE(`organization_id`, `user_id`),
KEY `member_organization_id` (`organization_id`),
KEY `member_user_id` (`user_id`)
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `invitation` (
`id` varchar(64) NOT NULL,
`organization_id` varchar(64) NOT NULL,
`email` varchar(255) NOT NULL,
`role` varchar(255) NOT NULL,
`status` varchar(32) NOT NULL DEFAULT 'pending',
`team_id` varchar(64) DEFAULT NULL,
`inviter_id` varchar(64) NOT NULL,
`expires_at` timestamp(3) NOT NULL,
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT `invitation_id` PRIMARY KEY(`id`),
KEY `invitation_organization_id` (`organization_id`),
KEY `invitation_email` (`email`),
KEY `invitation_status` (`status`),
KEY `invitation_team_id` (`team_id`)
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `team` (
`id` varchar(64) NOT NULL,
`name` varchar(255) NOT NULL,
`organization_id` varchar(64) NOT NULL,
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT `team_id` PRIMARY KEY(`id`),
CONSTRAINT `team_organization_name` UNIQUE(`organization_id`, `name`),
KEY `team_organization_id` (`organization_id`)
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `team_member` (
`id` varchar(64) NOT NULL,
`team_id` varchar(64) NOT NULL,
`user_id` varchar(64) NOT NULL,
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT `team_member_id` PRIMARY KEY(`id`),
CONSTRAINT `team_member_team_user` UNIQUE(`team_id`, `user_id`),
KEY `team_member_team_id` (`team_id`),
KEY `team_member_user_id` (`user_id`)
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `organization_role` (
`id` varchar(64) NOT NULL,
`organization_id` varchar(64) NOT NULL,
`role` varchar(255) NOT NULL,
`permission` text NOT NULL,
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT `organization_role_id` PRIMARY KEY(`id`),
CONSTRAINT `organization_role_name` UNIQUE(`organization_id`, `role`),
KEY `organization_role_organization_id` (`organization_id`)
);
--> statement-breakpoint
SET @has_legacy_org_table := (
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = 'org'
);
--> statement-breakpoint
SET @copy_legacy_org_sql := IF(
@has_legacy_org_table > 0,
'INSERT INTO `organization` (`id`, `name`, `slug`, `logo`, `metadata`, `created_at`, `updated_at`) SELECT `id`, `name`, `slug`, NULL, NULL, `created_at`, `updated_at` FROM `org` WHERE `id` NOT IN (SELECT `id` FROM `organization`)',
'SELECT 1'
);
--> statement-breakpoint
PREPARE copy_legacy_org_stmt FROM @copy_legacy_org_sql;
--> statement-breakpoint
EXECUTE copy_legacy_org_stmt;
--> statement-breakpoint
DEALLOCATE PREPARE copy_legacy_org_stmt;
--> statement-breakpoint
SET @has_legacy_org_membership_table := (
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = 'org_membership'
);
--> statement-breakpoint
SET @copy_legacy_org_membership_sql := IF(
@has_legacy_org_membership_table > 0,
'INSERT INTO `member` (`id`, `organization_id`, `user_id`, `role`, `created_at`) SELECT `id`, `org_id`, `user_id`, `role`, `created_at` FROM `org_membership` WHERE `id` NOT IN (SELECT `id` FROM `member`)',
'SELECT 1'
);
--> statement-breakpoint
PREPARE copy_legacy_org_membership_stmt FROM @copy_legacy_org_membership_sql;
--> statement-breakpoint
EXECUTE copy_legacy_org_membership_stmt;
--> statement-breakpoint
DEALLOCATE PREPARE copy_legacy_org_membership_stmt;

View File

@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS `temp_template_sharing` (
`id` varchar(64) NOT NULL,
`organization_id` varchar(64) NOT NULL,
`creator_member_id` varchar(64) NOT NULL,
`creator_user_id` varchar(64) NOT NULL,
`name` varchar(255) NOT NULL,
`template_json` text NOT NULL,
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT `temp_template_sharing_id` PRIMARY KEY(`id`),
KEY `temp_template_sharing_org_id` (`organization_id`),
KEY `temp_template_sharing_creator_member_id` (`creator_member_id`),
KEY `temp_template_sharing_creator_user_id` (`creator_user_id`)
);

View File

@@ -0,0 +1,88 @@
ALTER TABLE `team_member`
ADD COLUMN `org_membership_id` varchar(64) NULL AFTER `team_id`;
--> statement-breakpoint
UPDATE `team_member` tm
INNER JOIN `team` t ON t.`id` = tm.`team_id`
INNER JOIN `member` m ON m.`organization_id` = t.`organization_id` AND m.`user_id` = tm.`user_id`
SET tm.`org_membership_id` = m.`id`
WHERE tm.`org_membership_id` IS NULL;
--> statement-breakpoint
DROP INDEX `team_member_team_user` ON `team_member`;
--> statement-breakpoint
DROP INDEX `team_member_user_id` ON `team_member`;
--> statement-breakpoint
ALTER TABLE `team_member`
MODIFY COLUMN `org_membership_id` varchar(64) NOT NULL;
--> statement-breakpoint
CREATE INDEX `team_member_org_membership_id` ON `team_member` (`org_membership_id`);
--> statement-breakpoint
ALTER TABLE `team_member`
ADD CONSTRAINT `team_member_team_org_membership` UNIQUE(`team_id`, `org_membership_id`);
--> statement-breakpoint
ALTER TABLE `team_member`
DROP COLUMN `user_id`;
--> statement-breakpoint
CREATE TABLE `skill` (
`id` varchar(64) NOT NULL,
`organization_id` varchar(64) NOT NULL,
`created_by_org_membership_id` varchar(64) NOT NULL,
`title` varchar(255) NOT NULL,
`description` text,
`skill_text` text NOT NULL,
`shared` enum('org','public'),
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT `skill_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `skill_organization_id` ON `skill` (`organization_id`);
--> statement-breakpoint
CREATE INDEX `skill_created_by_org_membership_id` ON `skill` (`created_by_org_membership_id`);
--> statement-breakpoint
CREATE INDEX `skill_shared` ON `skill` (`shared`);
--> statement-breakpoint
CREATE TABLE `skill_hub` (
`id` varchar(64) NOT NULL,
`organization_id` varchar(64) NOT NULL,
`created_by_org_membership_id` varchar(64) NOT NULL,
`name` varchar(255) NOT NULL,
`description` text,
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT `skill_hub_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `skill_hub_organization_id` ON `skill_hub` (`organization_id`);
--> statement-breakpoint
CREATE INDEX `skill_hub_created_by_org_membership_id` ON `skill_hub` (`created_by_org_membership_id`);
--> statement-breakpoint
CREATE TABLE `skill_hub_skill` (
`id` varchar(64) NOT NULL,
`skill_hub_id` varchar(64) NOT NULL,
`skill_id` varchar(64) NOT NULL,
`org_membership_id` varchar(64) NOT NULL,
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT `skill_hub_skill_id` PRIMARY KEY(`id`),
CONSTRAINT `skill_hub_skill_hub_skill` UNIQUE(`skill_hub_id`, `skill_id`)
);
--> statement-breakpoint
CREATE INDEX `skill_hub_skill_skill_hub_id` ON `skill_hub_skill` (`skill_hub_id`);
--> statement-breakpoint
CREATE INDEX `skill_hub_skill_skill_id` ON `skill_hub_skill` (`skill_id`);
--> statement-breakpoint
CREATE TABLE `skill_hub_member` (
`id` varchar(64) NOT NULL,
`skill_hub_id` varchar(64) NOT NULL,
`org_membership_id` varchar(64),
`team_id` varchar(64),
`created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT `skill_hub_member_id` PRIMARY KEY(`id`),
CONSTRAINT `skill_hub_member_hub_org_membership` UNIQUE(`skill_hub_id`, `org_membership_id`),
CONSTRAINT `skill_hub_member_hub_team` UNIQUE(`skill_hub_id`, `team_id`)
);
--> statement-breakpoint
CREATE INDEX `skill_hub_member_skill_hub_id` ON `skill_hub_member` (`skill_hub_id`);
--> statement-breakpoint
CREATE INDEX `skill_hub_member_org_membership_id` ON `skill_hub_member` (`org_membership_id`);
--> statement-breakpoint
CREATE INDEX `skill_hub_member_team_id` ON `skill_hub_member` (`team_id`);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
{
"version": "7",
"dialect": "mysql",
"entries": [
{
"idx": 1,
"version": "5",
"when": 1775077000001,
"tag": "0001_desktop_handoff_grants",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1775077000002,
"tag": "0002_worker_activity_heartbeat",
"breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1775077000003,
"tag": "0003_rate_limit",
"breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1775077000004,
"tag": "0004_organization_plugin",
"breakpoints": true
},
{
"idx": 5,
"version": "5",
"when": 1775077000005,
"tag": "0005_temp_template_sharing",
"breakpoints": true
},
{
"idx": 6,
"version": "5",
"when": 1775077000006,
"tag": "0006_skill_hub",
"breakpoints": true
}
]
}

View File

@@ -31,6 +31,11 @@
"development": "./src/schema/teams.ts",
"default": "./dist/schema/teams.js"
},
"./schema/sharables/skills": {
"types": "./src/schema/sharables/skills.ts",
"development": "./src/schema/sharables/skills.ts",
"default": "./dist/schema/sharables/skills.js"
},
"./schema/workers": {
"types": "./src/schema/workers.ts",
"development": "./src/schema/workers.ts",

View File

@@ -1 +1 @@
export { and, asc, desc, eq, gt, isNotNull, isNull, sql } from "drizzle-orm"
export { and, asc, desc, eq, gt, inArray, isNotNull, isNull, or, sql } from "drizzle-orm"

View File

@@ -1,5 +1,6 @@
export * from "./auth"
export * from "./org"
export * from "./sharables/skills"
export * from "./teams"
export * from "./workers"
export * from "./system"

View File

@@ -1,4 +1,4 @@
import { sql } from "drizzle-orm"
import { relations, sql } from "drizzle-orm"
import { index, mysqlTable, text, timestamp, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { denTypeIdColumn } from "../columns"
@@ -110,11 +110,40 @@ export const TempTemplateSharingTable = mysqlTable(
],
)
export const organizationRelations = relations(OrganizationTable, ({ many }) => ({
members: many(MemberTable),
roles: many(OrganizationRoleTable),
tempTemplateSharings: many(TempTemplateSharingTable),
}))
export const memberRelations = relations(MemberTable, ({ many, one }) => ({
organization: one(OrganizationTable, {
fields: [MemberTable.organizationId],
references: [OrganizationTable.id],
}),
createdTempTemplateSharings: many(TempTemplateSharingTable),
}))
export const organizationRoleRelations = relations(OrganizationRoleTable, ({ one }) => ({
organization: one(OrganizationTable, {
fields: [OrganizationRoleTable.organizationId],
references: [OrganizationTable.id],
}),
}))
export const tempTemplateSharingRelations = relations(TempTemplateSharingTable, ({ one }) => ({
organization: one(OrganizationTable, {
fields: [TempTemplateSharingTable.organizationId],
references: [OrganizationTable.id],
}),
creatorMember: one(MemberTable, {
fields: [TempTemplateSharingTable.creatorMemberId],
references: [MemberTable.id],
}),
}))
export const organization = OrganizationTable
export const member = MemberTable
export const invitation = InvitationTable
export const organizationRole = OrganizationRoleTable
export const tempTemplateSharing = TempTemplateSharingTable
export const OrgTable = OrganizationTable
export const OrgMembershipTable = MemberTable

View File

@@ -0,0 +1,182 @@
import { relations, sql } from "drizzle-orm";
import {
index,
mysqlEnum,
mysqlTable,
text,
timestamp,
uniqueIndex,
varchar,
} from "drizzle-orm/mysql-core";
import { denTypeIdColumn } from "../../columns";
import { MemberTable, OrganizationTable } from "../org";
import { TeamTable } from "../teams";
export const SkillTable = mysqlTable(
"skill",
{
id: denTypeIdColumn("skill", "id").notNull().primaryKey(),
organizationId: denTypeIdColumn(
"organization",
"organization_id",
).notNull(),
createdByOrgMembershipId: denTypeIdColumn(
"member",
"created_by_org_membership_id",
).notNull(),
title: varchar("title", { length: 255 }).notNull(),
description: text("description"),
skillText: text("skill_text").notNull(),
shared: mysqlEnum("shared", ["org", "public"]),
createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { fsp: 3 })
.notNull()
.default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`),
},
(table) => [
index("skill_organization_id").on(table.organizationId),
index("skill_created_by_org_membership_id").on(
table.createdByOrgMembershipId,
),
index("skill_shared").on(table.shared),
],
);
export const SkillHubTable = mysqlTable(
"skill_hub",
{
id: denTypeIdColumn("skillHub", "id").notNull().primaryKey(),
organizationId: denTypeIdColumn(
"organization",
"organization_id",
).notNull(),
createdByOrgMembershipId: denTypeIdColumn(
"member",
"created_by_org_membership_id",
).notNull(),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { fsp: 3 })
.notNull()
.default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`),
},
(table) => [
index("skill_hub_organization_id").on(table.organizationId),
index("skill_hub_created_by_org_membership_id").on(
table.createdByOrgMembershipId,
),
],
);
export const SkillHubSkillTable = mysqlTable(
"skill_hub_skill",
{
id: denTypeIdColumn("skillHubSkill", "id").notNull().primaryKey(),
skillHubId: denTypeIdColumn("skillHub", "skill_hub_id").notNull(),
skillId: denTypeIdColumn("skill", "skill_id").notNull(),
addedByOrgMembershipId: denTypeIdColumn(
"member",
"org_membership_id",
).notNull(),
createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(),
},
(table) => [
index("skill_hub_skill_skill_hub_id").on(table.skillHubId),
index("skill_hub_skill_skill_id").on(table.skillId),
uniqueIndex("skill_hub_skill_hub_skill").on(
table.skillHubId,
table.skillId,
),
],
);
export const SkillHubMemberTable = mysqlTable(
"skill_hub_member",
{
id: denTypeIdColumn("skillHubMember", "id").notNull().primaryKey(),
skillHubId: denTypeIdColumn("skillHub", "skill_hub_id").notNull(),
orgMembershipId: denTypeIdColumn("member", "org_membership_id"),
teamId: denTypeIdColumn("team", "team_id"),
createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(),
},
(table) => [
index("skill_hub_member_skill_hub_id").on(table.skillHubId),
index("skill_hub_member_org_membership_id").on(table.orgMembershipId),
index("skill_hub_member_team_id").on(table.teamId),
uniqueIndex("skill_hub_member_hub_org_membership").on(
table.skillHubId,
table.orgMembershipId,
),
uniqueIndex("skill_hub_member_hub_team").on(
table.skillHubId,
table.teamId,
),
],
);
export const skillRelations = relations(SkillTable, ({ many, one }) => ({
organization: one(OrganizationTable, {
fields: [SkillTable.organizationId],
references: [OrganizationTable.id],
}),
createdByOrgMembership: one(MemberTable, {
fields: [SkillTable.createdByOrgMembershipId],
references: [MemberTable.id],
}),
skillHubLinks: many(SkillHubSkillTable),
}));
export const skillHubRelations = relations(SkillHubTable, ({ many, one }) => ({
organization: one(OrganizationTable, {
fields: [SkillHubTable.organizationId],
references: [OrganizationTable.id],
}),
createdByOrgMembership: one(MemberTable, {
fields: [SkillHubTable.createdByOrgMembershipId],
references: [MemberTable.id],
}),
skillLinks: many(SkillHubSkillTable),
memberLinks: many(SkillHubMemberTable),
}));
export const skillHubSkillRelations = relations(
SkillHubSkillTable,
({ one }) => ({
skillHub: one(SkillHubTable, {
fields: [SkillHubSkillTable.skillHubId],
references: [SkillHubTable.id],
}),
skill: one(SkillTable, {
fields: [SkillHubSkillTable.skillId],
references: [SkillTable.id],
}),
addedByOrgMembership: one(MemberTable, {
fields: [SkillHubSkillTable.addedByOrgMembershipId],
references: [MemberTable.id],
}),
}),
);
export const skillHubMemberRelations = relations(
SkillHubMemberTable,
({ one }) => ({
skillHub: one(SkillHubTable, {
fields: [SkillHubMemberTable.skillHubId],
references: [SkillHubTable.id],
}),
orgMembership: one(MemberTable, {
fields: [SkillHubMemberTable.orgMembershipId],
references: [MemberTable.id],
}),
team: one(TeamTable, {
fields: [SkillHubMemberTable.teamId],
references: [TeamTable.id],
}),
}),
);
export const skill = SkillTable;
export const skillHub = SkillHubTable;
export const skillHubSkill = SkillHubSkillTable;
export const skillHubMember = SkillHubMemberTable;

View File

@@ -1,6 +1,7 @@
import { sql } from "drizzle-orm"
import { relations, sql } from "drizzle-orm"
import { index, mysqlTable, timestamp, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { denTypeIdColumn } from "../columns"
import { MemberTable, OrganizationTable } from "./org"
export const TeamTable = mysqlTable(
"team",
@@ -24,15 +25,34 @@ export const TeamMemberTable = mysqlTable(
{
id: denTypeIdColumn("teamMember", "id").notNull().primaryKey(),
teamId: denTypeIdColumn("team", "team_id").notNull(),
userId: denTypeIdColumn("user", "user_id").notNull(),
orgMembershipId: denTypeIdColumn("member", "org_membership_id").notNull(),
createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(),
},
(table) => [
index("team_member_team_id").on(table.teamId),
index("team_member_user_id").on(table.userId),
uniqueIndex("team_member_team_user").on(table.teamId, table.userId),
index("team_member_org_membership_id").on(table.orgMembershipId),
uniqueIndex("team_member_team_org_membership").on(table.teamId, table.orgMembershipId),
],
)
export const teamRelations = relations(TeamTable, ({ many, one }) => ({
organization: one(OrganizationTable, {
fields: [TeamTable.organizationId],
references: [OrganizationTable.id],
}),
memberships: many(TeamMemberTable),
}))
export const teamMemberRelations = relations(TeamMemberTable, ({ one }) => ({
team: one(TeamTable, {
fields: [TeamMemberTable.teamId],
references: [TeamTable.id],
}),
orgMembership: one(MemberTable, {
fields: [TeamMemberTable.orgMembershipId],
references: [MemberTable.id],
}),
}))
export const team = TeamTable
export const teamMember = TeamMemberTable

View File

@@ -6,6 +6,7 @@ export default defineConfig({
schema: "src/schema.ts",
"schema/auth": "src/schema/auth.ts",
"schema/org": "src/schema/org.ts",
"schema/sharables/skills": "src/schema/sharables/skills.ts",
"schema/teams": "src/schema/teams.ts",
"schema/workers": "src/schema/workers.ts",
"schema/system": "src/schema/system.ts",

View File

@@ -14,6 +14,10 @@ export const denTypeIdPrefixes = {
invitation: "inv",
team: "tem",
teamMember: "tmb",
skill: "skl",
skillHub: "shb",
skillHubSkill: "shs",
skillHubMember: "shm",
organizationRole: "orl",
tempTemplateSharing: "tts",
adminAllowlist: "aal",

View File

@@ -9,18 +9,18 @@ COPY .npmrc /app/.npmrc
COPY patches /app/patches
COPY ee/packages/utils/package.json /app/ee/packages/utils/package.json
COPY ee/packages/den-db/package.json /app/ee/packages/den-db/package.json
COPY ee/apps/den-controller/package.json /app/ee/apps/den-controller/package.json
COPY ee/apps/den-api/package.json /app/ee/apps/den-api/package.json
RUN pnpm install --frozen-lockfile
COPY ee/packages/utils /app/ee/packages/utils
COPY ee/packages/den-db /app/ee/packages/den-db
COPY ee/apps/den-controller /app/ee/apps/den-controller
COPY ee/apps/den-api /app/ee/apps/den-api
RUN pnpm --dir /app/ee/packages/utils run build
RUN pnpm --dir /app/ee/packages/den-db run build
RUN pnpm --dir /app/ee/apps/den-controller run build
RUN pnpm --dir /app/ee/apps/den-api run build
EXPOSE 8788
CMD ["sh", "-lc", "yes | pnpm --dir /app/ee/packages/den-db run db:push && node ee/apps/den-controller/dist/index.js"]
CMD ["sh", "-lc", "yes | pnpm --dir /app/ee/packages/den-db run db:push && node ee/apps/den-api/dist/server.js"]

6
pnpm-lock.yaml generated
View File

@@ -419,9 +419,6 @@ importers:
dotenv:
specifier: ^16.4.5
version: 16.6.1
drizzle-orm:
specifier: ^0.45.1
version: 0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.11)(mysql2@3.17.4)
express:
specifier: ^4.19.2
version: 4.22.1
@@ -441,9 +438,6 @@ importers:
'@types/node':
specifier: ^20.11.30
version: 20.12.12
drizzle-kit:
specifier: ^0.31.9
version: 0.31.9
tsx:
specifier: ^4.15.7
version: 4.21.0