Skip to content
126 linesCodeBlameRaw
1/**
2 * A workspace's chat settings (migrations/0003_chat_controls.sql) and who
3 * they let do what. Pure, so they are tested apart from the service, which
4 * enforces every one of them (src/index.ts).
5 */
6import type { ChannelManagers, ChatAllowed, ChatPermissions, ChatSettings, EmojiUpload } from "@g1t/contracts";
7
8import { mayUpload } from "./emoji.ts";
9
10/** A `chat_settings` row as kept; no row is the defaults. */
11export type SettingsRow = {
12 emoji_upload: string | null;
13 public_channels: string | null;
14 private_channels: string | null;
15 manage_channels: string | null;
16 default_channels: string | null;
17};
18
19/** A workspace role, or null for someone not in it. */
20export type Role = "owner" | "member" | null;
21
22/** The most channels someone new is put in. */
23export const MAX_DEFAULT_CHANNELS = 20;
24
25function allowed(value: unknown): ChatAllowed {
26 return value === "owners" ? "owners" : "members";
27}
28
29/**
30 * The settings a row keeps, with the defaults for anything it does not.
31 * Default channels never chosen are #general (`generalId`, when it exists).
32 */
33export function settingsOf(row: SettingsRow | null, generalId: string | null = null): ChatSettings {
34 let defaults: string[] = row?.default_channels == null && generalId ? [generalId] : [];
35 try {
36 const parsed = row?.default_channels != null ? (JSON.parse(row.default_channels) as unknown) : null;
37 if (Array.isArray(parsed)) defaults = parsed.filter((id): id is string => typeof id === "string").slice(0, MAX_DEFAULT_CHANNELS);
38 } catch {
39 defaults = [];
40 }
41 return {
42 public_channels: allowed(row?.public_channels),
43 private_channels: allowed(row?.private_channels),
44 manage_channels: row?.manage_channels === "owners" ? "owners" : "channel_owners",
45 emoji_upload: row?.emoji_upload === "admins" ? "admins" : "members",
46 default_channels: defaults,
47 };
48}
49
50/** Whether a role may make a public or a private channel. */
51export function mayCreateChannel(settings: ChatSettings, role: Role, isPrivate: boolean): boolean {
52 if (!role) return false;
53 const who = isPrivate ? settings.private_channels : settings.public_channels;
54 return who === "members" || role === "owner";
55}
56
57/**
58 * Whether someone may rename, archive or unarchive a channel: a workspace
59 * owner always; its own owner (whoever made it) unless the workspace keeps
60 * that to its owners.
61 */
62export function mayManageChannel(settings: ChatSettings, role: Role, channelRole: "owner" | "member" | null): boolean {
63 if (role === "owner") return true;
64 if (!role) return false;
65 return settings.manage_channels === "channel_owners" && channelRole === "owner";
66}
67
68/** What a role may do, for the page to show or hide. */
69export function permissionsFor(settings: ChatSettings, role: Role): ChatPermissions {
70 return {
71 create_public_channels: mayCreateChannel(settings, role, false),
72 create_private_channels: mayCreateChannel(settings, role, true),
73 add_emoji: mayUpload(settings.emoji_upload, role),
74 manage_settings: role === "owner",
75 };
76}
77
78/**
79 * A change to the settings, checked: each field left out stays, each one
80 * given must be a value the setting takes. Default channels are checked
81 * against the workspace's channels by the service.
82 */
83export function settingsChange(
84 input: unknown,
85): { ok: true; change: Partial<ChatSettings> } | { ok: false; message: string } {
86 if (!input || typeof input !== "object") return { ok: false, message: "Nothing to change." };
87 const c = input as Record<string, unknown>;
88 const change: Partial<ChatSettings> = {};
89 const who = (key: "public_channels" | "private_channels") => {
90 if (c[key] === undefined) return true;
91 if (c[key] !== "members" && c[key] !== "owners") return false;
92 change[key] = c[key] as ChatAllowed;
93 return true;
94 };
95 if (!who("public_channels") || !who("private_channels")) return { ok: false, message: "Choose members or owners." };
96 if (c.manage_channels !== undefined) {
97 if (c.manage_channels !== "channel_owners" && c.manage_channels !== "owners") {
98 return { ok: false, message: "Choose channel owners or workspace owners." };
99 }
100 change.manage_channels = c.manage_channels as ChannelManagers;
101 }
102 if (c.emoji_upload !== undefined) {
103 if (c.emoji_upload !== "members" && c.emoji_upload !== "admins") return { ok: false, message: "Choose members or owners." };
104 change.emoji_upload = c.emoji_upload as EmojiUpload;
105 }
106 if (c.default_channels !== undefined) {
107 if (!Array.isArray(c.default_channels) || !c.default_channels.every((id) => typeof id === "string")) {
108 return { ok: false, message: "Default channels are a list of channels." };
109 }
110 const ids = [...new Set(c.default_channels as string[])];
111 if (ids.length > MAX_DEFAULT_CHANNELS) return { ok: false, message: `Choose at most ${MAX_DEFAULT_CHANNELS} default channels.` };
112 change.default_channels = ids;
113 }
114 return { ok: true, change };
115}
116
117/** The settings once a change is made, as a row's columns. */
118export function rowFor(settings: ChatSettings): Omit<SettingsRow, "default_channels"> & { default_channels: string } {
119 return {
120 emoji_upload: settings.emoji_upload,
121 public_channels: settings.public_channels,
122 private_channels: settings.private_channels,
123 manage_channels: settings.manage_channels,
124 default_channels: JSON.stringify(settings.default_channels),
125 };
126}