forked from atums.world/atums.world
add invites, fix apiauth query, use luxon for date management, change readme
This commit is contained in:
parent
9fcaac4dfb
commit
9a91f1e7e3
10 changed files with 436 additions and 29 deletions
|
@ -1,3 +1 @@
|
||||||
# bun frontend template
|
# atums.world
|
||||||
|
|
||||||
a simle bun frontend starting point i made and use
|
|
||||||
|
|
54
config/sql/invites.ts
Normal file
54
config/sql/invites.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import { logger } from "@helpers/logger";
|
||||||
|
import { type ReservedSQL, sql } from "bun";
|
||||||
|
|
||||||
|
export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
|
let selfReservation: boolean = false;
|
||||||
|
|
||||||
|
if (!reservation) {
|
||||||
|
reservation = await sql.reserve();
|
||||||
|
selfReservation = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await reservation`
|
||||||
|
CREATE TABLE IF NOT EXISTS invites (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL UNIQUE,
|
||||||
|
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
expiration TIMESTAMPTZ DEFAULT NULL,
|
||||||
|
uses INTEGER DEFAULT 0,
|
||||||
|
max_uses INTEGER DEFAULT 1,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user'
|
||||||
|
);`;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(["Could not create the invites table:", error as Error]);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (selfReservation) {
|
||||||
|
reservation.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function drop(
|
||||||
|
cascade: boolean,
|
||||||
|
reservation?: ReservedSQL,
|
||||||
|
): Promise<void> {
|
||||||
|
let selfReservation: boolean = false;
|
||||||
|
|
||||||
|
if (!reservation) {
|
||||||
|
reservation = await sql.reserve();
|
||||||
|
selfReservation = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await reservation`DROP TABLE IF EXISTS invites ${cascade ? "CASCADE" : ""};`;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(["Could not drop the invites table:", error as Error]);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (selfReservation) {
|
||||||
|
reservation.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,6 +13,7 @@
|
||||||
"@eslint/js": "^9.21.0",
|
"@eslint/js": "^9.21.0",
|
||||||
"@types/bun": "^1.2.4",
|
"@types/bun": "^1.2.4",
|
||||||
"@types/ejs": "^3.1.5",
|
"@types/ejs": "^3.1.5",
|
||||||
|
"@types/luxon": "^3.4.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.25.0",
|
"@typescript-eslint/eslint-plugin": "^8.25.0",
|
||||||
"@typescript-eslint/parser": "^8.25.0",
|
"@typescript-eslint/parser": "^8.25.0",
|
||||||
"eslint": "^9.21.0",
|
"eslint": "^9.21.0",
|
||||||
|
@ -30,6 +31,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"fast-jwt": "^5.0.5",
|
"fast-jwt": "^5.0.5",
|
||||||
|
"luxon": "^3.5.0",
|
||||||
"redis": "^4.7.0"
|
"redis": "^4.7.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,7 +24,7 @@ export async function authByToken(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: UserSession[] =
|
const result: UserSession[] =
|
||||||
await reservation`SELECT id, username, email, roles avatar, timezone, authorization_token FROM users WHERE authorization_token = ${authorizationToken};`;
|
await reservation`SELECT id, username, email, roles, avatar, timezone, authorization_token FROM users WHERE authorization_token = ${authorizationToken};`;
|
||||||
|
|
||||||
if (result.length === 0) return null;
|
if (result.length === 0) return null;
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
|
||||||
export function timestampToReadable(timestamp?: number): string {
|
export function timestampToReadable(timestamp?: number): string {
|
||||||
const date: Date =
|
const date: Date =
|
||||||
timestamp && !isNaN(timestamp) ? new Date(timestamp) : new Date();
|
timestamp && !isNaN(timestamp) ? new Date(timestamp) : new Date();
|
||||||
|
@ -11,3 +13,90 @@ export function isUUID(uuid: string): boolean {
|
||||||
|
|
||||||
return regex.test(uuid);
|
return regex.test(uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getNewTimeUTC(
|
||||||
|
durationStr: string,
|
||||||
|
from: DateTime = DateTime.utc(),
|
||||||
|
): string | null {
|
||||||
|
const duration: DurationObject = parseDuration(durationStr);
|
||||||
|
|
||||||
|
const newTime: DateTime = from.plus({
|
||||||
|
years: duration.years,
|
||||||
|
months: duration.months,
|
||||||
|
weeks: duration.weeks,
|
||||||
|
days: duration.days,
|
||||||
|
hours: duration.hours,
|
||||||
|
minutes: duration.minutes,
|
||||||
|
seconds: duration.seconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
return newTime.toSQL({ includeOffset: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDuration(input: string): DurationObject {
|
||||||
|
const regex: RegExp = /(\d+)(y|mo|w|d|h|m|s)/g;
|
||||||
|
const matches: RegExpMatchArray[] = [...input.matchAll(regex)];
|
||||||
|
|
||||||
|
const duration: DurationObject = {
|
||||||
|
years: 0,
|
||||||
|
months: 0,
|
||||||
|
weeks: 0,
|
||||||
|
days: 0,
|
||||||
|
hours: 0,
|
||||||
|
minutes: 0,
|
||||||
|
seconds: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
const value: number = parseInt(match[1], 10);
|
||||||
|
const unit: string = match[2];
|
||||||
|
|
||||||
|
switch (unit) {
|
||||||
|
case "y":
|
||||||
|
duration.years = value;
|
||||||
|
break;
|
||||||
|
case "mo":
|
||||||
|
duration.months = value;
|
||||||
|
break;
|
||||||
|
case "w":
|
||||||
|
duration.weeks = value;
|
||||||
|
break;
|
||||||
|
case "d":
|
||||||
|
duration.days = value;
|
||||||
|
break;
|
||||||
|
case "h":
|
||||||
|
duration.hours = value;
|
||||||
|
break;
|
||||||
|
case "m":
|
||||||
|
duration.minutes = value;
|
||||||
|
break;
|
||||||
|
case "s":
|
||||||
|
duration.seconds = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidTimezone(timezone: string): boolean {
|
||||||
|
return DateTime.local().setZone(timezone).isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRandomString(length?: number): string {
|
||||||
|
if (!length) {
|
||||||
|
length = length || Math.floor(Math.random() * 10) + 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
const characters: string =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
let result: string = "";
|
||||||
|
|
||||||
|
for (let i: number = 0; i < length; i++) {
|
||||||
|
result += characters.charAt(
|
||||||
|
Math.floor(Math.random() * characters.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
|
@ -6,7 +6,6 @@ import {
|
||||||
isValidUsername,
|
isValidUsername,
|
||||||
} from "@config/sql/users";
|
} from "@config/sql/users";
|
||||||
import { password as bunPassword, type ReservedSQL, sql } from "bun";
|
import { password as bunPassword, type ReservedSQL, sql } from "bun";
|
||||||
import type { UUID } from "crypto";
|
|
||||||
|
|
||||||
import { logger } from "@/helpers/logger";
|
import { logger } from "@/helpers/logger";
|
||||||
import { sessionManager } from "@/helpers/sessions";
|
import { sessionManager } from "@/helpers/sessions";
|
||||||
|
@ -56,14 +55,14 @@ async function handler(
|
||||||
|
|
||||||
const reservation: ReservedSQL = await sql.reserve();
|
const reservation: ReservedSQL = await sql.reserve();
|
||||||
let firstUser: boolean = false;
|
let firstUser: boolean = false;
|
||||||
let invitedBy: UUID | null = null;
|
let inviteData: Invite | null = null;
|
||||||
let roles: string[] = [];
|
let roles: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const registrationEnabled: boolean =
|
const registrationEnabled: boolean =
|
||||||
(await getSetting("registrationEnabled", reservation)) === "true";
|
(await getSetting("enable_registration", reservation)) === "true";
|
||||||
const invitationsEnabled: boolean =
|
const invitationsEnabled: boolean =
|
||||||
(await getSetting("invitationsEnabled", reservation)) === "true";
|
(await getSetting("enable_invitations", reservation)) === "true";
|
||||||
|
|
||||||
firstUser =
|
firstUser =
|
||||||
Number(
|
Number(
|
||||||
|
@ -87,23 +86,30 @@ async function handler(
|
||||||
}
|
}
|
||||||
|
|
||||||
roles.push("user");
|
roles.push("user");
|
||||||
if (firstUser) {
|
if (firstUser) roles.push("admin");
|
||||||
roles.push("admin");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { usernameExists, emailExists } = await reservation`
|
const result: { usernameExists: boolean; emailExists: boolean }[] =
|
||||||
|
await reservation`
|
||||||
SELECT
|
SELECT
|
||||||
EXISTS(SELECT 1 FROM users WHERE LOWER(username) = LOWER(${username})) AS usernameExists,
|
EXISTS(SELECT 1 FROM users WHERE LOWER(username) = LOWER(${username})) AS "usernameExists",
|
||||||
EXISTS(SELECT 1 FROM users WHERE LOWER(email) = LOWER(${email})) AS emailExists;
|
EXISTS(SELECT 1 FROM users WHERE LOWER(email) = LOWER(${email})) AS "emailExists";
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (usernameExists) errors.push("Username already exists");
|
const { usernameExists, emailExists } = result[0] || {};
|
||||||
if (emailExists) errors.push("Email already exists");
|
|
||||||
|
if (usernameExists || emailExists) {
|
||||||
|
errors.push("Username or email already exists");
|
||||||
|
}
|
||||||
|
|
||||||
if (invite) {
|
if (invite) {
|
||||||
invitedBy = (
|
const result: Invite[] =
|
||||||
await reservation`SELECT user_id FROM invites WHERE invite = ${invite};`
|
await reservation`SELECT * FROM invites WHERE id = ${invite};`;
|
||||||
)[0]?.id;
|
|
||||||
if (!invitedBy) errors.push("Invalid invite code");
|
if (!result || result.length === 0) {
|
||||||
|
errors.push("Invalid invite");
|
||||||
|
}
|
||||||
|
|
||||||
|
inviteData = result[0];
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push("An error occurred while checking for existing users");
|
errors.push("An error occurred while checking for existing users");
|
||||||
|
@ -129,13 +135,25 @@ async function handler(
|
||||||
(await getSetting("default_timezone", reservation)) || "UTC";
|
(await getSetting("default_timezone", reservation)) || "UTC";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
user = (
|
const result: User[] = await reservation`
|
||||||
await reservation`
|
|
||||||
INSERT INTO users (username, email, password, invited_by, roles, timezone)
|
INSERT INTO users (username, email, password, invited_by, roles, timezone)
|
||||||
VALUES (${username}, ${email}, ${hashedPassword}, ${invitedBy}, ARRAY[${roles.join(",")}]::TEXT[], ${defaultTimezone})
|
VALUES (${username}, ${email}, ${hashedPassword}, ${inviteData?.created_by}, ARRAY[${roles.join(",")}]::TEXT[], ${defaultTimezone})
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
`
|
`;
|
||||||
)[0];
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
logger.error("User was not created");
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 500,
|
||||||
|
error: "An error occurred with the user registration",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
user = result[0];
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
logger.error("User was not created");
|
logger.error("User was not created");
|
||||||
|
@ -149,8 +167,17 @@ async function handler(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invitedBy) {
|
if (invite) {
|
||||||
await reservation`DELETE FROM invites WHERE invite = ${invite};`;
|
const maxUses: number = Number(inviteData?.max_uses) || 1;
|
||||||
|
const uses: number = Number(inviteData?.uses) || 0;
|
||||||
|
|
||||||
|
if (uses + 1 >= maxUses) {
|
||||||
|
await reservation`DELETE FROM invites WHERE id = ${inviteData?.id};`;
|
||||||
|
} else {
|
||||||
|
await reservation`UPDATE invites SET uses = ${uses + 1} WHERE id = ${inviteData?.id};`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inviteData?.role) roles.push(inviteData.role);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error([
|
logger.error([
|
||||||
|
|
126
src/routes/api/invite/create.ts
Normal file
126
src/routes/api/invite/create.ts
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
import { getSetting } from "@config/sql/settings";
|
||||||
|
import { sql } from "bun";
|
||||||
|
|
||||||
|
import { generateRandomString, getNewTimeUTC } from "@/helpers/char";
|
||||||
|
import { logger } from "@/helpers/logger";
|
||||||
|
|
||||||
|
const routeDef: RouteDef = {
|
||||||
|
method: "POST",
|
||||||
|
accepts: "application/json",
|
||||||
|
returns: "application/json",
|
||||||
|
needsBody: "json",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handler(
|
||||||
|
request: ExtendedRequest,
|
||||||
|
requestBody: unknown,
|
||||||
|
): Promise<Response> {
|
||||||
|
if (!request.session) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 403,
|
||||||
|
error: "Unauthorized",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!getSetting("enable_invitations")) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 403,
|
||||||
|
error: "Invitations are disabled",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin: boolean = request.session.roles.includes("admin");
|
||||||
|
|
||||||
|
if (!isAdmin && !getSetting("allow_user_invites")) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 403,
|
||||||
|
error: "User invitations are disabled",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { expires, max_uses, role } = requestBody as {
|
||||||
|
expires?: string;
|
||||||
|
max_uses?: number;
|
||||||
|
role?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (role && !isAdmin) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 403,
|
||||||
|
error: "You must be an admin to set the role",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expirationDate: string | null = expires
|
||||||
|
? getNewTimeUTC(expires)
|
||||||
|
: null;
|
||||||
|
const maxUses: number = Number(max_uses) || 1;
|
||||||
|
const inviteRole: string = role || "user";
|
||||||
|
|
||||||
|
let invite: Invite | null = null;
|
||||||
|
try {
|
||||||
|
const result: Invite[] = await sql`
|
||||||
|
INSERT INTO invites (created_by, expiration, max_uses, role, id)
|
||||||
|
VALUES (${request.session.id}, ${expirationDate}, ${maxUses}, ${inviteRole}, ${generateRandomString(15)})
|
||||||
|
RETURNING *;
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
logger.error("Invite failed to create");
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 500,
|
||||||
|
error: "Invite was not created",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
invite = result[0];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(["Error creating invite:", error as Error]);
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 500,
|
||||||
|
error: "An error occurred while creating the invite",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
invite: {
|
||||||
|
code: invite.id,
|
||||||
|
expiration: invite.expiration,
|
||||||
|
max_uses: invite.max_uses,
|
||||||
|
role: invite.role,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { handler, routeDef };
|
92
src/routes/api/invite/delete[invite].ts
Normal file
92
src/routes/api/invite/delete[invite].ts
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
import { type ReservedSQL, sql } from "bun";
|
||||||
|
|
||||||
|
import { logger } from "@/helpers/logger";
|
||||||
|
|
||||||
|
const routeDef: RouteDef = {
|
||||||
|
method: "DELETE",
|
||||||
|
accepts: "*/*",
|
||||||
|
returns: "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
|
if (!request.session) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 403,
|
||||||
|
error: "Unauthorized",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin: boolean = request.session.roles.includes("admin");
|
||||||
|
const { invite } = request.params as { invite: string };
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 400,
|
||||||
|
error: "Expected invite",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservation: ReservedSQL = await sql.reserve();
|
||||||
|
let inviteData: Invite | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result: Invite[] =
|
||||||
|
await reservation`SELECT * FROM invites WHERE id = ${invite};`;
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 400,
|
||||||
|
error: "Invalid invite",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
inviteData = result[0];
|
||||||
|
|
||||||
|
if (!isAdmin && inviteData.created_by !== request.session.id) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 403,
|
||||||
|
error: "Unauthorized",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await reservation`DELETE FROM invites WHERE id = ${inviteData.id};`;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(["Could not get the invite:", error as Error]);
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 500,
|
||||||
|
error: "Internal server error",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
message: "Invite deleted",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { handler, routeDef };
|
9
types/char.d.ts
vendored
Normal file
9
types/char.d.ts
vendored
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
type DurationObject = {
|
||||||
|
years: number;
|
||||||
|
months: number;
|
||||||
|
weeks: number;
|
||||||
|
days: number;
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
seconds: number;
|
||||||
|
};
|
10
types/session.d.ts
vendored
10
types/session.d.ts
vendored
|
@ -27,3 +27,13 @@ type User = {
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
last_seen: Date;
|
last_seen: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Invite = {
|
||||||
|
id: UUID;
|
||||||
|
created_by: UUID;
|
||||||
|
created_at: Date;
|
||||||
|
expiration: Date | null;
|
||||||
|
uses: number;
|
||||||
|
max_uses: number;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
|
Loading…
Add table
Reference in a new issue