Add guild routes, setup tables, validators, and update environment example
All checks were successful
Code quality checks / biome (push) Successful in 7s

Added routes for guild creation, deletion, joining, leaving, and invites
Set up Cassandra tables for guilds, invites, and members
Added validators for guild input
Updated .env.example with required config values
This commit is contained in:
creations 2025-05-03 13:56:57 -04:00
parent 9d8b3eb969
commit 320e2cc121
Signed by: creations
GPG key ID: 8F553AA4320FC711
14 changed files with 708 additions and 24 deletions

View file

@ -1,19 +1,31 @@
# Environment
# NODE_ENV=development
HOST=0.0.0.0
PORT=8080
# Redis (Required)
REDIS_URL=redis://localhost:6379
REDIS_TTL=3600
REDIS_TTL=3600 # Time-to-live in seconds (default: 1 hour)
# Cassandra (Required)
CASSANDRA_HOST=localhost
CASSANDRA_PORT=9042
CASSANDRA_DATACENTER=datacenter1
CASSANDRA_CONTACT_POINTS=localhost
CASSANDRA_KEYSPACE=void_db
# Optional
CASSANDRA_KEYSPACE=
# Cassandra (Optional)
CASSANDRA_USERNAME=
CASSANDRA_AUTH_ENABLED=false
CASSANDRA_PASSWORD=
# JWT (Required)
JWT_SECRET=your_secret_here
JWT_EXPIRATION=1d
JWT_ISSUER=void
# JWT (Optional)
JWT_ALGORITHM=HS256
# Frontend (Required)
FRONTEND_ORIGIN=http://localhost:3000

View file

@ -7,6 +7,10 @@ const environment: Environment = {
process.env.NODE_ENV === "development" || process.argv.includes("--dev"),
};
const frontend: FrontendConfig = {
origin: process.env.FRONTEND_ORIGIN || "",
};
function verifyRequiredVariables(): void {
const requiredVariables = [
"HOST",
@ -24,6 +28,8 @@ function verifyRequiredVariables(): void {
"JWT_SECRET",
"JWT_EXPIRATION",
"JWT_ISSUER",
"FRONTEND_ORIGIN",
];
let hasError = false;
@ -45,4 +51,4 @@ export * from "@config/cassandra";
export * from "@config/jwt";
export * from "@config/redis";
export { environment, verifyRequiredVariables };
export { environment, frontend, verifyRequiredVariables };

View file

@ -1,10 +1,39 @@
import { readdir } from "node:fs/promises";
import { readdir, stat } from "node:fs/promises";
import { extname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { cassandra as cassandraConfig, verifyRequiredVariables } from "@config";
import { logger } from "@creations.works/logger";
import { cassandra } from "@lib/cassandra";
async function loadTables(dir: string): Promise<void> {
const entries = await readdir(dir);
for (const entry of entries) {
const fullPath = join(dir, entry);
const stats = await stat(fullPath);
if (stats.isDirectory()) {
await loadTables(fullPath);
continue;
}
if (extname(entry) !== ".ts") continue;
const modulePath = pathToFileURL(fullPath).href;
const mod = await import(modulePath);
if (typeof mod.default === "function") {
await mod.default();
logger.info(`Ran default export from ${fullPath}`);
} else if (typeof mod.createTable === "function") {
await mod.createTable();
logger.info(`Ran createTable from ${fullPath}`);
} else {
logger.warn(`No callable export found in ${fullPath}`);
}
}
}
async function setup(): Promise<void> {
verifyRequiredVariables();
await cassandra.connect({ withKeyspace: false });
@ -29,24 +58,7 @@ async function setup(): Promise<void> {
logger.info(`Keyspace "${keyspace}" ensured and selected.`);
const tablesDir = resolve("config", "setup", "tables");
const files = await readdir(tablesDir);
for (const file of files) {
if (extname(file) !== ".ts") continue;
const modulePath = pathToFileURL(join(tablesDir, file)).href;
const mod = await import(modulePath);
if (typeof mod.default === "function") {
await mod.default();
logger.info(`Ran default export from ${file}`);
} else if (typeof mod.createTable === "function") {
await mod.createTable();
logger.info(`Ran createTable from ${file}`);
} else {
logger.warn(`No callable export found in ${file}`);
}
}
await loadTables(tablesDir);
logger.info("Setup complete.");
}
@ -58,7 +70,7 @@ setup()
})
.finally(() => {
cassandra.shutdown().catch((error: Error) => {
logger.error(["Error shutting down Cassandra client:", error as Error]);
logger.error(["Error shutting down Cassandra client:", error]);
});
process.exit(0);

View file

@ -0,0 +1,34 @@
import { cassandra } from "@lib/cassandra";
async function createTable() {
const client = cassandra.getClient();
// main guilds table
await client.execute(`
CREATE TABLE IF NOT EXISTS guilds (
id TEXT PRIMARY KEY,
name TEXT,
icon TEXT,
owner_id TEXT,
system_channel_id TEXT,
rules_channel_id TEXT,
anouncements_channel_id TEXT,
created_at TIMESTAMP,
updated_at TIMESTAMP
);
`);
// lookup table to enforce unique guild names per user
await client.execute(`
CREATE TABLE IF NOT EXISTS guilds_by_owner (
owner_id TEXT,
name TEXT,
guild_id TEXT,
PRIMARY KEY ((owner_id), name)
);
`);
}
export { createTable };

View file

@ -0,0 +1,20 @@
import { cassandra } from "@lib/cassandra";
async function createTable() {
const client = cassandra.getClient();
await client.execute(`
CREATE TABLE IF NOT EXISTS guild_invites (
invite_code TEXT PRIMARY KEY,
guild_id TEXT,
created_by TEXT,
created_at TIMESTAMP,
expires_at TIMESTAMP,
max_uses INT,
uses INT,
is_revoked BOOLEAN
);
`);
}
export { createTable };

View file

@ -0,0 +1,32 @@
import { cassandra } from "@lib/cassandra";
async function createTable() {
const client = cassandra.getClient();
await client.execute(`
CREATE TABLE IF NOT EXISTS guild_members (
guild_id TEXT,
user_id TEXT,
roles SET<TEXT>,
joined_at TIMESTAMP,
is_banned BOOLEAN,
invite_id TEXT,
PRIMARY KEY ((guild_id), user_id)
);
`);
// reverse lookup table for all guilds a user is in
await client.execute(`
CREATE TABLE IF NOT EXISTS members_by_user (
user_id TEXT,
guild_id TEXT,
roles SET<TEXT>,
joined_at TIMESTAMP,
is_banned BOOLEAN,
invite_id TEXT,
PRIMARY KEY ((user_id), guild_id)
);
`);
}
export { createTable };

View file

@ -0,0 +1,29 @@
const guildNameRestrictions: genericValidation = {
length: { min: 3, max: 100 },
regex: /^[\p{L}\p{N} ._-]+$/u,
};
function isValidGuildName(rawName: string): validationResult {
const name = rawName.trim().normalize("NFC");
if (!name) return { valid: false, error: "Guild name is required" };
if (name.length < guildNameRestrictions.length.min)
return { valid: false, error: "Guild name is too short" };
if (name.length > guildNameRestrictions.length.max)
return { valid: false, error: "Guild name is too long" };
if (!guildNameRestrictions.regex.test(name))
return { valid: false, error: "Guild name contains invalid characters" };
if (/^[._ -]|[._ -]$/.test(name))
return {
valid: false,
error: "Guild name can't start or end with special characters",
};
return { valid: true, name };
}
export { guildNameRestrictions, isValidGuildName };

View file

@ -0,0 +1,92 @@
import { logger } from "@creations.works/logger";
import { cassandra } from "@lib/cassandra";
import { jsonResponse } from "@lib/http";
import type { Client } from "cassandra-driver";
const routeDef: RouteDef = {
method: "DELETE",
accepts: "application/json",
returns: "application/json;charset=utf-8",
};
async function handler(request: ExtendedRequest): Promise<Response> {
const user: UserSession | null = request.session;
if (!user) {
return jsonResponse(401, {
message: "Unauthorized",
error: "You must be logged in to delete a guild",
});
}
const { id: guildId } = request.params;
const { name: providedName } = request.query;
if (!guildId || !providedName) {
return jsonResponse(400, {
message: "Missing parameters",
error: "Both guild ID and name are required",
});
}
const client: Client = cassandra.getClient();
const result = await client.execute(
"SELECT name, owner_id FROM guilds WHERE id = ?",
[guildId],
{ prepare: true },
);
if (result.rowLength === 0) {
return jsonResponse(404, {
message: "Not Found",
error: "Guild does not exist",
});
}
const row = result.first();
if (row.owner_id !== user.id) {
return jsonResponse(403, {
message: "Forbidden",
error: "You do not own this guild",
});
}
if (row.name !== providedName) {
return jsonResponse(409, {
message: "Conflict",
error: "Guild name does not match provided value",
});
}
try {
await client.execute("DELETE FROM guilds WHERE id = ?", [guildId], {
prepare: true,
});
await client.execute(
"DELETE FROM guilds_by_owner WHERE owner_id = ? AND name = ?",
[user.id, row.name],
{ prepare: true },
);
logger.custom(
"[GUILD DELETE]",
`(${guildId})`,
`${row.name} - Owner: ${user.id}`,
"31",
);
return jsonResponse(200, {
message: "Guild deleted successfully",
data: { id: guildId, name: row.name },
});
} catch (error) {
return jsonResponse(500, {
message: "Internal server error",
error: "Failed to delete guild",
});
}
}
export { handler, routeDef };

View file

@ -0,0 +1,110 @@
import { frontend } from "@config";
import { logger } from "@creations.works/logger";
import { cassandra } from "@lib/cassandra";
import { jsonResponse } from "@lib/http";
import { pika } from "@lib/pika";
import type { Client } from "cassandra-driver";
const routeDef: RouteDef = {
method: "POST",
accepts: "application/json",
returns: "application/json;charset=utf-8",
needsBody: "json",
};
async function handler(
request: ExtendedRequest,
requestBody: unknown,
): Promise<Response> {
const user: UserSession | null = request.session;
if (!user) {
return jsonResponse(401, {
message: "Unauthorized",
error: "You must be logged in to create an invite",
});
}
const { id: guildId } = request.params;
if (!guildId) {
return jsonResponse(400, {
message: "Missing guild ID",
});
}
const { max_uses, expires_at } = requestBody as {
max_uses?: number;
expires_at?: string | null;
};
const cassandraClient: Client = cassandra.getClient();
// TODO: When permissions are implemented, check if the user has permission to create an invite
const guild = await cassandraClient.execute(
"SELECT owner_id FROM guilds WHERE id = ?",
[guildId],
{ prepare: true },
);
if (guild.rowLength === 0) {
return jsonResponse(404, {
message: "Guild not found",
});
}
const guildData = guild.first();
if (guildData.owner_id !== user.id) {
return jsonResponse(403, {
message: "Forbidden",
error: "You are not the owner of this guild",
});
}
const inviteCode = pika.gen("invite");
const now = new Date();
const expiresAt = expires_at ? new Date(expires_at) : null;
try {
await cassandraClient.execute(
`INSERT INTO guild_invites (
invite_code, guild_id, created_by, created_at,
expires_at, max_uses, uses, is_revoked
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
inviteCode,
guildId,
user.id,
now,
expiresAt,
max_uses ?? null,
0,
false,
],
{ prepare: true },
);
logger.custom(
"[GUILD INVITE]",
`(${inviteCode})`,
`Guild: ${guildId} by ${user.id}`,
"35",
);
return jsonResponse(201, {
message: "Invite created successfully",
data: {
url: `${frontend.origin}/invite/${inviteCode}`, // TODO: Update this to use the correct route ?
invite: inviteCode,
guild: guildId,
expires_at: expiresAt,
max_uses: max_uses ?? null,
},
});
} catch (error) {
return jsonResponse(500, {
message: "Internal server error",
error: "Failed to create invite",
});
}
}
export { handler, routeDef };

View file

@ -0,0 +1,96 @@
import { logger } from "@creations.works/logger";
import { cassandra } from "@lib/cassandra";
import { jsonResponse } from "@lib/http";
import type { Client } from "cassandra-driver";
const routeDef: RouteDef = {
method: "DELETE",
accepts: "*/*",
returns: "application/json;charset=utf-8",
};
async function handler(request: ExtendedRequest): Promise<Response> {
const user: UserSession | null = request.session;
if (!user) {
return jsonResponse(401, {
message: "Unauthorized",
error: "You must be logged in to leave a guild",
});
}
const { id: guildId } = request.params;
if (!guildId) {
return jsonResponse(400, {
message: "Missing guild ID",
});
}
const client: Client = cassandra.getClient();
const guild = await client.execute(
"SELECT owner_id FROM guilds WHERE id = ?",
[guildId],
{ prepare: true },
);
if (guild.rowLength === 0) {
return jsonResponse(404, {
message: "Guild not found",
});
}
const ownerId = guild.first().owner_id;
if (ownerId === user.id) {
return jsonResponse(403, {
message: "You cannot leave a guild you own",
});
}
const membership = await client.execute(
"SELECT user_id FROM guild_members WHERE guild_id = ? AND user_id = ?",
[guildId, user.id],
{ prepare: true },
);
if (membership.rowLength === 0) {
return jsonResponse(409, {
message: "You are not a member of this guild",
});
}
try {
await client.execute(
"DELETE FROM guild_members WHERE guild_id = ? AND user_id = ?",
[guildId, user.id],
{ prepare: true },
);
await client.execute(
"DELETE FROM members_by_user WHERE user_id = ? AND guild_id = ?",
[user.id, guildId],
{ prepare: true },
);
logger.custom(
"[GUILD LEAVE]",
`(${guildId})`,
`User ${user.id} left`,
"33",
);
return jsonResponse(200, {
message: "Left guild successfully",
data: {
guild_id: guildId,
},
});
} catch (error) {
logger.error(error as Error);
return jsonResponse(500, {
message: "Internal server error",
error: "Failed to leave guild",
});
}
}
export { handler, routeDef };

104
src/routes/guild/create.ts Normal file
View file

@ -0,0 +1,104 @@
import { isValidGuildName } from "@/lib/validators/guild";
import { logger } from "@creations.works/logger";
import { cassandra } from "@lib/cassandra";
import { jsonResponse } from "@lib/http";
import { pika } from "@lib/pika";
import type { Client } from "cassandra-driver";
const routeDef: RouteDef = {
method: "POST",
accepts: "application/json",
returns: "application/json;charset=utf-8",
needsBody: "json",
};
async function handler(
request: ExtendedRequest,
requestBody: unknown,
): Promise<Response> {
const { name } = requestBody as { name: string };
const user: UserSession | null = request.session;
if (!user) {
return jsonResponse(401, {
message: "Unauthorized",
error: "You must be logged in to create a guild",
});
}
const errors: string[] = [];
const { valid, name: validName, error } = isValidGuildName(name.trim());
if (!valid) {
errors.push(error || "Invalid guild name");
}
if (errors.length > 0) {
return jsonResponse(400, {
message: "Validation failed",
error: errors.join("; "),
});
}
const cassandraClient: Client = cassandra.getClient();
const existing = await cassandraClient.execute(
"SELECT guild_id FROM guilds_by_owner WHERE owner_id = ? AND name = ?",
[user.id, validName],
{ prepare: true },
);
if (existing.rowLength > 0) {
return jsonResponse(409, {
message: "Conflict",
error: "You already have a guild with this name",
});
}
const guildId = pika.gen("guild");
const now = new Date();
try {
await cassandraClient.execute(
`INSERT INTO guilds (
id, name, owner_id,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?)`,
[guildId, validName, user.id, now, now],
{ prepare: true },
);
await cassandraClient.execute(
`INSERT INTO guilds_by_owner (
owner_id, name, guild_id
) VALUES (?, ?, ?)`,
[user.id, validName, guildId],
{ prepare: true },
);
logger.custom(
"[GUILD CREATE]",
`(${guildId})`,
`${validName} - Owner: ${user.id}`,
"36",
);
return jsonResponse(201, {
message: "Guild created successfully",
data: {
id: guildId,
name: validName,
owner: user.id,
created_at: now,
updated_at: now,
},
});
} catch (error) {
return jsonResponse(500, {
message: "Internal server error",
error: "Failed to create guild",
});
}
}
export { handler, routeDef };

View file

@ -0,0 +1,132 @@
import { logger } from "@creations.works/logger";
import { cassandra } from "@lib/cassandra";
import { jsonResponse } from "@lib/http";
import type { Client } from "cassandra-driver";
const routeDef: RouteDef = {
method: "POST",
accepts: "*/*",
returns: "application/json;charset=utf-8",
};
async function handler(request: ExtendedRequest): Promise<Response> {
const user: UserSession | null = request.session;
if (!user) {
return jsonResponse(401, {
message: "Unauthorized",
error: "You must be logged in to join a guild",
});
}
const { id: invite } = request.params;
if (!invite) {
return jsonResponse(400, {
message: "Missing invite code",
error: "Invite code is required",
});
}
const client: Client = cassandra.getClient();
const inviteResult = await client.execute(
"SELECT * FROM guild_invites WHERE invite_code = ?",
[invite],
{ prepare: true },
);
if (inviteResult.rowLength === 0) {
return jsonResponse(404, {
message: "Invalid invite code",
});
}
const inviteData = inviteResult.first();
if (inviteData.is_revoked) {
return jsonResponse(403, {
message: "This invite has been revoked",
});
}
if (inviteData.expires_at && inviteData.expires_at < new Date()) {
return jsonResponse(403, {
message: "This invite has expired",
});
}
if (inviteData.max_uses !== null && inviteData.uses >= inviteData.max_uses) {
return jsonResponse(403, {
message: "This invite has reached its usage limit",
});
}
const guildId = inviteData.guild_id;
const memberResult = await client.execute(
"SELECT is_banned FROM guild_members WHERE guild_id = ? AND user_id = ?",
[guildId, user.id],
{ prepare: true },
);
if (memberResult.rowLength > 0) {
const member = memberResult.first();
if (member.is_banned) {
return jsonResponse(403, {
message: "You are banned from this guild",
});
}
return jsonResponse(409, {
message: "You are already a member of this guild",
});
}
const now = new Date();
try {
await client.execute(
`INSERT INTO guild_members (
guild_id, user_id, roles, joined_at, is_banned, invite_id
) VALUES (?, ?, ?, ?, ?, ?)`,
[guildId, user.id, ["member"], now, false, invite],
{ prepare: true },
);
await client.execute(
`INSERT INTO members_by_user (
user_id, guild_id, roles, joined_at, is_banned, invite_id
) VALUES (?, ?, ?, ?, ?, ?)`,
[user.id, guildId, ["member"], now, false, invite],
{ prepare: true },
);
await client.execute(
"UPDATE guild_invites SET uses = ? WHERE invite_code = ?",
[inviteData.uses + 1, invite],
{ prepare: true },
);
logger.custom(
"[GUILD JOIN VIA INVITE]",
`(${guildId})`,
`User ${user.id} joined with invite ${invite}`,
"32",
);
return jsonResponse(200, {
message: "Joined guild successfully",
data: {
guild_id: guildId,
role: "member",
},
});
} catch (error) {
logger.error(error as Error);
return jsonResponse(500, {
message: "Internal server error",
error: "Failed to join guild",
});
}
}
export { handler, routeDef };

4
types/config.d.ts vendored
View file

@ -21,3 +21,7 @@ type JWTConfig = {
issuer: string;
algorithm: string;
};
type FrontendConfig = {
origin: string;
};

View file

@ -7,4 +7,5 @@ type validationResult = {
valid: boolean;
error?: string;
username?: string;
name?: string;
};