Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions PIVOT.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,13 +258,18 @@ Three buckets. Execute in order: deletes first on a branch, get to a minimal cha
- `packages/db/src/schemas/user-privacy-settings.ts` + `apps/api/src/routes/v1/privacy-settings/` (peer-to-peer privacy controls don't apply inside a tenant)
- `realtime/src/services/blocks.ts` block enforcement in DMs (the `user-blocks` table itself stays for now per maintainer call — UI hidden)

**Per-guild role / permission system** (collapse to `member | admin | owner`):
- `packages/db/src/schemas/guild-roles.ts` (role definitions + permission strings)
- `packages/db/src/schemas/guild-bans.ts` (bans + timeouts — remove-from-workspace is enough)
- `communication_timeout` field on `guild-members.ts`
- Role/ban/timeout endpoints in `apps/api/src/routes/v1/guilds/`
- Roles / bans / moderation panes in `apps/web/src/components/guild/`
- Role-permission helpers in `packages/auth/src/lib/permissions.ts`
**Banishment and timeouts — gone for good:**
- `packages/db/src/schemas/guild-bans.ts`
- `communicationDisabledUntil` / `communicationDisabledBy` / `communicationDisabledReason` fields on `guild-members.ts`
- `banGuildMember`, `timeoutGuildMember`, `clearGuildMemberTimeout` endpoints in `apps/api/src/routes/v1/guilds/`
- Ban / timeout UI in `apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx`
- `isCommunicationDisabled` / `assertMemberCanCommunicate` helpers in `apps/api/src/lib/permissions.ts`

**Granular permission system — KEPT** (decision reversed 2026-05-28):
- The better-auth `createAccessControl` system in `packages/auth/src/lib/permissions.ts` stays. `guild-roles.ts` schema stays. Dynamic per-guild role grants stay. `assertGuildPermission(actor, guild, { channel: ["update"] })` pattern stays — it scales better than `if role === "admin"` sprinkled in handlers.
- Trims to the system for Lor scope: drop `announcement` statement (no announcement channels), drop `ban`/`timeout` actions from `guildMember` (features removed), drop the `warden` role (no moderator tier; teams can define their own moderator role via the dynamic `guild_role` table), drop "Citizen" label → "Member."
Comment thread
BuckyMcYolo marked this conversation as resolved.
- Final core roles: `owner`, `admin`, `member`. Assignable via API: `["admin", "member"]`.
- `role` column on `guild_member` is plain `text` (no DB enum constraint) — better-auth pattern, allows dynamic role names.

**Channel types we don't need:**
- `announcement` (Decrees) — B2B teams don't broadcast like communities
Expand Down
17 changes: 0 additions & 17 deletions apps/api/src/lib/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,20 +111,3 @@ export function assertCanManageGuildMember(
targetAuthority,
}
}

export function isCommunicationDisabled(
member: Pick<GuildMember, "communicationDisabledUntil">
) {
if (!member.communicationDisabledUntil) return false
return member.communicationDisabledUntil.getTime() > Date.now()
}

export function assertMemberCanCommunicate(
member: Pick<GuildMember, "communicationDisabledUntil">
) {
if (!isCommunicationDisabled(member)) return

throw new HTTPException(HttpStatusCodes.FORBIDDEN, {
message: "You are temporarily timed out and cannot send messages",
})
}
234 changes: 0 additions & 234 deletions apps/api/src/routes/v1/guilds/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,9 @@ import {
import { getRedisClient } from "@/lib/redis"
import type { AppRouteHandler } from "@/lib/types/app-types"
import type {
BanGuildMemberRoute,
ClearGuildMemberTimeoutRoute,
KickGuildMemberRoute,
ListGuildMembersRoute,
SearchMessagesRoute,
TimeoutGuildMemberRoute,
UpdateGuildMemberRoleRoute,
UpdateGuildRoute,
} from "@/routes/v1/guilds/routes"
Expand Down Expand Up @@ -65,8 +62,6 @@ function toGuildMemberPresence(
displayUsername: string | null
image: string | null
role: string
communicationDisabledUntil: Date | null
communicationDisabledReason: string | null
},
ownerId: string,
onlineUserIds: Set<string>
Expand All @@ -82,9 +77,6 @@ function toGuildMemberPresence(
status: onlineUserIds.has(member.userId)
? ("online" as const)
: ("offline" as const),
communicationDisabledUntil:
member.communicationDisabledUntil?.toISOString() ?? null,
communicationDisabledReason: member.communicationDisabledReason,
}
}

Expand All @@ -93,9 +85,6 @@ async function getGuildMemberRow(guildId: string, userId: string) {
.select({
userId: schema.guildMember.userId,
role: schema.guildMember.role,
communicationDisabledUntil: schema.guildMember.communicationDisabledUntil,
communicationDisabledReason:
schema.guildMember.communicationDisabledReason,
name: schema.user.name,
username: schema.user.username,
displayUsername: schema.user.displayUsername,
Expand All @@ -122,9 +111,6 @@ export const listGuildMembers: AppRouteHandler<ListGuildMembersRoute> = async (
.select({
userId: schema.guildMember.userId,
role: schema.guildMember.role,
communicationDisabledUntil: schema.guildMember.communicationDisabledUntil,
communicationDisabledReason:
schema.guildMember.communicationDisabledReason,
name: schema.user.name,
username: schema.user.username,
displayUsername: schema.user.displayUsername,
Expand Down Expand Up @@ -253,226 +239,6 @@ export const kickGuildMember: AppRouteHandler<KickGuildMemberRoute> = async (
return c.json({ success: true as const }, HttpStatusCodes.OK)
}

export const banGuildMember: AppRouteHandler<BanGuildMemberRoute> = async (
c
) => {
const guild = c.var.guild
const actor = c.var.member
const { userId } = c.req.valid("param")
const { reason, expiresAt } = c.req.valid("json")

assertGuildPermission(actor, guild, {
guildMember: ["ban"],
})

const target = await getGuildMemberRow(guild.id, userId)

if (!target) {
return c.json(
{ success: false, message: "Guild member not found" },
HttpStatusCodes.NOT_FOUND
)
}

assertCanManageGuildMember(actor, target, guild)

const expiresAtDate = expiresAt ? new Date(expiresAt) : null
const banTimestamp = new Date()

const ban = await db.transaction(async (tx) => {
const insertedBan = await tx
.insert(schema.guildBan)
.values({
createdAt: banTimestamp,
guildId: guild.id,
userId,
bannedBy: actor.userId,
reason: reason ?? null,
expiresAt: expiresAtDate,
revokedAt: null,
revokeReason: null,
})
.onConflictDoUpdate({
target: [schema.guildBan.guildId, schema.guildBan.userId],
set: {
createdAt: banTimestamp,
bannedBy: actor.userId,
reason: reason ?? null,
expiresAt: expiresAtDate,
revokedAt: null,
revokeReason: null,
},
})
.returning({
userId: schema.guildBan.userId,
guildId: schema.guildBan.guildId,
bannedBy: schema.guildBan.bannedBy,
reason: schema.guildBan.reason,
expiresAt: schema.guildBan.expiresAt,
createdAt: schema.guildBan.createdAt,
revokedAt: schema.guildBan.revokedAt,
})
.then((rows) => rows[0])

await tx
.delete(schema.guildMember)
.where(
and(
eq(schema.guildMember.guildId, guild.id),
eq(schema.guildMember.userId, userId)
)
)

return insertedBan
})

if (!ban) {
return c.json(
{ success: false, message: "Failed to create guild ban" },
HttpStatusCodes.INTERNAL_SERVER_ERROR
)
}

return c.json(
{
success: true as const,
ban: {
...ban,
reason: ban.reason ?? null,
expiresAt: ban.expiresAt?.toISOString() ?? null,
createdAt: ban.createdAt.toISOString(),
revokedAt: ban.revokedAt?.toISOString() ?? null,
},
},
HttpStatusCodes.OK
)
}

export const timeoutGuildMember: AppRouteHandler<
TimeoutGuildMemberRoute
> = async (c) => {
const guild = c.var.guild
const actor = c.var.member
const { userId } = c.req.valid("param")
const { durationMinutes, reason } = c.req.valid("json")

assertGuildPermission(actor, guild, {
guildMember: ["timeout"],
})

const target = await getGuildMemberRow(guild.id, userId)

if (!target) {
return c.json(
{ success: false, message: "Guild member not found" },
HttpStatusCodes.NOT_FOUND
)
}

assertCanManageGuildMember(actor, target, guild)

const communicationDisabledUntil = new Date(
Date.now() + durationMinutes * 60 * 1000
)

await db
.update(schema.guildMember)
.set({
communicationDisabledUntil,
communicationDisabledBy: actor.userId,
communicationDisabledReason: reason ?? null,
})
.where(
and(
eq(schema.guildMember.guildId, guild.id),
eq(schema.guildMember.userId, userId)
)
)

const updatedMember = await getGuildMemberRow(guild.id, userId)

if (!updatedMember) {
return c.json(
{ success: false, message: "Guild member not found" },
HttpStatusCodes.NOT_FOUND
)
}

const onlineUserIds = await listOnlineUserIds([updatedMember.userId])

return c.json(
{
success: true as const,
member: toGuildMemberPresence(
updatedMember,
guild.ownerId,
onlineUserIds
),
},
HttpStatusCodes.OK
)
}

export const clearGuildMemberTimeout: AppRouteHandler<
ClearGuildMemberTimeoutRoute
> = async (c) => {
const guild = c.var.guild
const actor = c.var.member
const { userId } = c.req.valid("param")

assertGuildPermission(actor, guild, {
guildMember: ["timeout"],
})

const target = await getGuildMemberRow(guild.id, userId)

if (!target) {
return c.json(
{ success: false, message: "Guild member not found" },
HttpStatusCodes.NOT_FOUND
)
}

assertCanManageGuildMember(actor, target, guild)

await db
.update(schema.guildMember)
.set({
communicationDisabledUntil: null,
communicationDisabledBy: null,
communicationDisabledReason: null,
})
.where(
and(
eq(schema.guildMember.guildId, guild.id),
eq(schema.guildMember.userId, userId)
)
)

const updatedMember = await getGuildMemberRow(guild.id, userId)

if (!updatedMember) {
return c.json(
{ success: false, message: "Guild member not found" },
HttpStatusCodes.NOT_FOUND
)
}

const onlineUserIds = await listOnlineUserIds([updatedMember.userId])

return c.json(
{
success: true as const,
member: toGuildMemberPresence(
updatedMember,
guild.ownerId,
onlineUserIds
),
},
HttpStatusCodes.OK
)
}

// ── Guild Settings ─────────────────────────────────────

export const updateGuild: AppRouteHandler<UpdateGuildRoute> = async (c) => {
Expand Down
3 changes: 0 additions & 3 deletions apps/api/src/routes/v1/guilds/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,5 @@ const guildsRouter = createRouter()
.openapi(routes.updateGuild, handlers.updateGuild)
.openapi(routes.updateGuildMemberRole, handlers.updateGuildMemberRole)
.openapi(routes.kickGuildMember, handlers.kickGuildMember)
.openapi(routes.banGuildMember, handlers.banGuildMember)
.openapi(routes.timeoutGuildMember, handlers.timeoutGuildMember)
.openapi(routes.clearGuildMemberTimeout, handlers.clearGuildMemberTimeout)

export default guildsRouter
Loading
Loading