merge whitelist and direct invitations
deploy / build-and-deploy (push) Successful in 40s

This commit is contained in:
bytedream committed 2026-11-07 21:23:11 +01:00
1 parent 5d52405353
commit b40665c2d7
12 files changed
+200 -112

No files matched your search

+3
View File
@@ -87,6 +87,9 @@ export const signup = {
if (input.code) { if (input.code) {
await tx.setDirectSignupUser({ code: input.code, userId: user.id }); await tx.setDirectSignupUser({ code: input.code, userId: user.id });
} }
if (uuid) {
await tx.linkDirectSignupUserByUuid({ uuid: uuid, userId: user.id });
}
}); });
sendWebhook(WebhookAction.Signup, { sendWebhook(WebhookAction.Signup, {
+27 -2
View File
@@ -35,6 +35,10 @@ export const user = {
uuid: input.uuid uuid: input.uuid
}); });
if (input.uuid) {
await db.linkDirectSignupUserByUuid({ uuid: input.uuid, userId: id });
}
return { return {
id: id id: id
}; };
@@ -146,10 +150,31 @@ export const user = {
} }
}), }),
addDirectInvitation: defineAction({ addDirectInvitation: defineAction({
handler: async (_, context) => { input: z
.object({
uuid: z.string().nullable(),
comment: z.string().nullable()
})
.optional(),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Users); Session.actionSessionFromCookies(context.cookies, Permissions.Users);
return await db.addDirectSignup({}); return await db.addDirectSignup({
uuid: input?.uuid ?? null,
comment: input?.comment ?? null
});
}
}),
editDirectInvitation: defineAction({
input: z.object({
code: z.string(),
uuid: z.string().nullable(),
comment: z.string().nullable()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
await db.editDirectSignup(input);
} }
}), }),
deleteDirectInvitation: defineAction({ deleteDirectInvitation: defineAction({
@@ -1,13 +1,38 @@
<script lang="ts"> <script lang="ts">
import DataTable from '@components/admin/table/DataTable.svelte'; import DataTable from '@components/admin/table/DataTable.svelte';
import CrudPopup from '@components/admin/popup/CrudPopup.svelte';
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts'; import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
import { import {
deleteDirectInvitation, deleteDirectInvitation,
editDirectInvitation,
type DirectInvitation, type DirectInvitation,
directInvitations directInvitations
} from '@app/admin/directInvitations/directInvitations.ts'; } from '@app/admin/directInvitations/directInvitations.ts';
// callback // state
let directInvitationEditPopupDirectInvitation = $state(null);
let directInvitationEditPopupOpen = $derived(!!directInvitationEditPopupDirectInvitation);
let copiedCode = $state<string | null>(null);
// lifecycle
$effect(() => {
if (!directInvitationEditPopupOpen) directInvitationEditPopupDirectInvitation = null;
});
// callbacks
async function copyLink(directInvitation: DirectInvitation) {
try {
await navigator.clipboard.writeText(directInvitation.url);
} catch {
return;
}
copiedCode = directInvitation.code;
setTimeout(() => {
if (copiedCode === directInvitation.code) copiedCode = null;
}, 2000);
}
function onDirectInvitationDelete(directInvitation: DirectInvitation) { function onDirectInvitationDelete(directInvitation: DirectInvitation) {
$confirmPopupState = { $confirmPopupState = {
title: 'Direkte Einladung löschen?', title: 'Direkte Einladung löschen?',
@@ -21,8 +46,46 @@
data={directInvitations} data={directInvitations}
count={true} count={true}
keys={[ keys={[
{ key: 'url', label: 'Link' }, { key: 'code', label: 'Code', sortable: true, transform: codeTransform },
{ key: 'uuid', label: 'UUID', sortable: true },
{ key: 'comment', label: 'Kommentar' },
{ key: 'user.username', label: 'Registrierter Nutzer', sortable: true } { key: 'user.username', label: 'Registrierter Nutzer', sortable: true }
]} ]}
onEdit={(directInvitation) => (directInvitationEditPopupDirectInvitation = directInvitation)}
onDelete={onDirectInvitationDelete} onDelete={onDirectInvitationDelete}
>
{#snippet codeTransform(code: string)}
<span class="font-mono text-xs">{code}</span>
{/snippet}
{#snippet extraActions(directInvitation)}
<button
class="cursor-pointer"
title={copiedCode === directInvitation.code ? 'Link kopiert' : 'Link kopieren'}
onclick={() => copyLink(directInvitation)}
>
{#if copiedCode === directInvitation.code}
<span class="iconify iconify-[heroicons--clipboard-document-check]"></span>
{:else}
<span class="iconify iconify-[heroicons--clipboard]"></span>
{/if}
</button>
{/snippet}
</DataTable>
<CrudPopup
texts={{
title: 'Direkte Einladung bearbeiten',
submitButtonTitle: 'Speichern',
confirmPopupTitle: 'Änderungen speichern',
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
}}
target={directInvitationEditPopupDirectInvitation}
keys={[
[{ key: 'code', type: 'text', label: 'Code', options: { required: true, readonly: true, dynamicWidth: true } }],
[{ key: 'uuid', type: 'text', label: 'UUID', options: { dynamicWidth: true } }],
[{ key: 'comment', type: 'textarea', label: 'Kommentar', options: { dynamicWidth: true } }]
]}
onSubmit={editDirectInvitation}
bind:open={directInvitationEditPopupOpen}
/> />
@@ -26,6 +26,10 @@
confirmPopupMessage: 'Bist du sicher, dass die direkte Einladung erstellt werden soll?.' confirmPopupMessage: 'Bist du sicher, dass die direkte Einladung erstellt werden soll?.'
}} }}
target={null} target={null}
keys={[
[{ key: 'uuid', type: 'text', label: 'UUID (optional)', default: null, options: { dynamicWidth: true } }],
[{ key: 'comment', type: 'textarea', label: 'Kommentar', default: null, options: { dynamicWidth: true } }]
]}
onSubmit={addDirectInvitation} onSubmit={addDirectInvitation}
bind:open={createPopupOpen} bind:open={createPopupOpen}
/> />
@@ -1,7 +1,7 @@
import { type ActionReturnType, actions } from 'astro:actions'; import { type ActionReturnType, actions } from 'astro:actions';
import { writable } from 'svelte/store'; import { get, writable } from 'svelte/store';
import { actionErrorPopup } from '@util/action.ts'; import { actionErrorPopup } from '@util/action.ts';
import { addToWritableArray, deleteFromWritableArray } from '@util/state.ts'; import { addToWritableArray, deleteFromWritableArray, updateWritableArray } from '@util/state.ts';
// types // types
export type DirectInvitations = Exclude< export type DirectInvitations = Exclude<
@@ -9,6 +9,11 @@ export type DirectInvitations = Exclude<
undefined undefined
>['directInvitations']; >['directInvitations'];
export type DirectInvitation = DirectInvitations[0]; export type DirectInvitation = DirectInvitations[0];
export type DirectInvitationInput = {
code?: string;
uuid: string | null;
comment: string | null;
};
// state // state
export const directInvitations = writable<DirectInvitations>([]); export const directInvitations = writable<DirectInvitations>([]);
@@ -24,18 +29,41 @@ export async function fetchDirectInvitations() {
directInvitations.set(data.directInvitations); directInvitations.set(data.directInvitations);
} }
export async function addDirectInvitation() { export async function addDirectInvitation(directInvitation: DirectInvitationInput) {
const { data, error } = await actions.user.addDirectInvitation(); const { data, error } = await actions.user.addDirectInvitation({
uuid: directInvitation.uuid,
comment: directInvitation.comment
});
if (error) { if (error) {
actionErrorPopup(error); actionErrorPopup(error);
return; return;
} }
addToWritableArray(directInvitations, data); addToWritableArray(directInvitations, { ...data, user: null });
}
export async function editDirectInvitation(directInvitation: DirectInvitationInput) {
const { error } = await actions.user.editDirectInvitation({
code: directInvitation.code!,
uuid: directInvitation.uuid,
comment: directInvitation.comment
});
if (error) {
actionErrorPopup(error);
return;
}
const existing = get(directInvitations).find((t) => t.code === directInvitation.code);
updateWritableArray(
directInvitations,
{ ...existing, ...directInvitation } as DirectInvitation,
(t) => t.code === directInvitation.code
);
} }
export async function deleteDirectInvitation(directInvitation: DirectInvitation) { export async function deleteDirectInvitation(directInvitation: DirectInvitation) {
const { error } = await actions.user.deleteDirectInvitation(directInvitation); const { error } = await actions.user.deleteDirectInvitation({ code: directInvitation.code });
if (error) { if (error) {
actionErrorPopup(error); actionErrorPopup(error);
return; return;
@@ -89,6 +89,7 @@
convert?: (value: KeyInputTypeOptionsConvert<T>) => V; convert?: (value: KeyInputTypeOptionsConvert<T>) => V;
validate?: (value: V) => boolean; validate?: (value: V) => boolean;
required?: boolean; required?: boolean;
readonly?: boolean;
dynamicWidth?: boolean; dynamicWidth?: boolean;
}; };
@@ -193,6 +194,7 @@
bind:value={() => target[k.key] ?? k.default, (v) => onBindChange(k.key, v, k.options)} bind:value={() => target[k.key] ?? k.default, (v) => onBindChange(k.key, v, k.options)}
label={k.label} label={k.label}
required={k.options?.required} required={k.options?.required}
readonly={k.options?.readonly}
dynamicWidth={k.options?.dynamicWidth} dynamicWidth={k.options?.dynamicWidth}
/> />
{:else if k.type === 'textarea'} {:else if k.type === 'textarea'}
@@ -230,6 +232,7 @@
onSubmit={(user) => onBindChange(k.key, user, k.options)} onSubmit={(user) => onBindChange(k.key, user, k.options)}
label={k.label} label={k.label}
required={k.options?.required} required={k.options?.required}
readonly={k.options?.readonly}
mustMatch={k.options?.mustMatch} mustMatch={k.options?.mustMatch}
/> />
{:else if k.type === 'password'} {:else if k.type === 'password'}
+3 -9
View File
@@ -28,16 +28,10 @@ CREATE TABLE IF NOT EXISTS blocked_user (
-- direct signup -- direct signup
CREATE TABLE IF NOT EXISTS direct_signup ( CREATE TABLE IF NOT EXISTS direct_signup (
code CHAR(255) NOT NULL UNIQUE, code CHAR(255) NOT NULL UNIQUE,
user_id INT, uuid VARCHAR(36) UNIQUE,
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE
);
-- whitelist user
CREATE TABLE IF NOT EXISTS whitelist_user (
uuid VARCHAR(36) NOT NULL PRIMARY KEY,
comment TINYTEXT, comment TINYTEXT,
user_id INT UNIQUE, user_id INT,
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE SET NULL FOREIGN KEY (user_id) REFERENCES user(id)
); );
-- report -- report
+6 -19
View File
@@ -125,24 +125,17 @@ import {
deleteDirectSignup, deleteDirectSignup,
type DeleteDirectSignupReq, type DeleteDirectSignupReq,
directSignup, directSignup,
editDirectSignup,
type EditDirectSignupReq,
getDirectSignupByCode, getDirectSignupByCode,
type GetDirectSignupByCodeReq, type GetDirectSignupByCodeReq,
getDirectSignups, getDirectSignups,
type GetDirectSignupsReq, type GetDirectSignupsReq,
linkDirectSignupUserByUuid,
type LinkDirectSignupUserByUuidReq,
setDirectSignupUser, setDirectSignupUser,
type SetDirectSignupUserReq type SetDirectSignupUserReq
} from '@db/schema/directSignup.ts'; } from '@db/schema/directSignup.ts';
import {
addWhitelistUser,
type AddWhitelistUserReq,
deleteWhitelistUser,
type DeleteWhitelistUserReq,
editWhitelistUser,
type EditWhitelistUserReq,
getWhitelistUsers,
type GetWhitelistUsersReq,
whitelistUser
} from '@db/schema/whitelistUser.ts';
export class Database { export class Database {
protected readonly db: MySql2Database<{ protected readonly db: MySql2Database<{
@@ -150,7 +143,6 @@ export class Database {
user: typeof user; user: typeof user;
blockedUser: typeof blockedUser; blockedUser: typeof blockedUser;
directSignup: typeof directSignup; directSignup: typeof directSignup;
whitelistUser: typeof whitelistUser;
report: typeof report; report: typeof report;
reportAttachment: typeof reportAttachment; reportAttachment: typeof reportAttachment;
reportStatus: typeof reportStatus; reportStatus: typeof reportStatus;
@@ -176,7 +168,6 @@ export class Database {
user, user,
blockedUser, blockedUser,
directSignup, directSignup,
whitelistUser,
report, report,
reportAttachment, reportAttachment,
reportStatus, reportStatus,
@@ -223,14 +214,10 @@ export class Database {
addDirectSignup = (values: AddDirectSignupReq) => addDirectSignup(this.db, values); addDirectSignup = (values: AddDirectSignupReq) => addDirectSignup(this.db, values);
setDirectSignupUser = (values: SetDirectSignupUserReq) => setDirectSignupUser(this.db, values); setDirectSignupUser = (values: SetDirectSignupUserReq) => setDirectSignupUser(this.db, values);
deleteDirectSignup = (values: DeleteDirectSignupReq) => deleteDirectSignup(this.db, values); deleteDirectSignup = (values: DeleteDirectSignupReq) => deleteDirectSignup(this.db, values);
editDirectSignup = (values: EditDirectSignupReq) => editDirectSignup(this.db, values);
getDirectSignups = (values: GetDirectSignupsReq) => getDirectSignups(this.db, values); getDirectSignups = (values: GetDirectSignupsReq) => getDirectSignups(this.db, values);
getDirectSignupByCode = (values: GetDirectSignupByCodeReq) => getDirectSignupByCode(this.db, values); getDirectSignupByCode = (values: GetDirectSignupByCodeReq) => getDirectSignupByCode(this.db, values);
linkDirectSignupUserByUuid = (values: LinkDirectSignupUserByUuidReq) => linkDirectSignupUserByUuid(this.db, values);
/* whitelist */
addWhitelistUser = (values: AddWhitelistUserReq) => addWhitelistUser(this.db, values);
editWhitelistUser = (values: EditWhitelistUserReq) => editWhitelistUser(this.db, values);
deleteWhitelistUser = (values: DeleteWhitelistUserReq) => deleteWhitelistUser(this.db, values);
getWhitelistUsers = (values: GetWhitelistUsersReq) => getWhitelistUsers(this.db, values);
/* report */ /* report */
addReport = (values: AddReportReq) => addReport(this.db, values); addReport = (values: AddReportReq) => addReport(this.db, values);
+52 -7
View File
@@ -1,7 +1,7 @@
import type { MySql2Database } from 'drizzle-orm/mysql2'; import type { MySql2Database } from 'drizzle-orm/mysql2';
import { int, mysqlTable, varchar } from 'drizzle-orm/mysql-core'; import { int, mysqlTable, text, varchar } from 'drizzle-orm/mysql-core';
import { user } from '@db/schema/user.ts'; import { user } from '@db/schema/user.ts';
import { eq } from 'drizzle-orm'; import { and, eq, isNull } from 'drizzle-orm';
import { generateRandomString } from '@util/random.ts'; import { generateRandomString } from '@util/random.ts';
import { siteUrl } from '@util/url.ts'; import { siteUrl } from '@util/url.ts';
@@ -9,16 +9,32 @@ type Database = MySql2Database<{ directSignup: typeof directSignup }>;
export const directSignup = mysqlTable('direct_signup', { export const directSignup = mysqlTable('direct_signup', {
code: varchar('code', { length: 255 }).unique().notNull(), code: varchar('code', { length: 255 }).unique().notNull(),
uuid: varchar('uuid', { length: 36 }).unique(),
comment: text('comment'),
userId: int('user_id').references(() => user.id) userId: int('user_id').references(() => user.id)
}); });
export type AddDirectSignupReq = {}; export type AddDirectSignupReq = {
uuid?: string | null;
comment?: string | null;
};
export type EditDirectSignupReq = {
code: string;
uuid?: string | null;
comment?: string | null;
};
export type SetDirectSignupUserReq = { export type SetDirectSignupUserReq = {
code: string; code: string;
userId: number; userId: number;
}; };
export type LinkDirectSignupUserByUuidReq = {
uuid: string;
userId: number;
};
export type DeleteDirectSignupReq = { export type DeleteDirectSignupReq = {
code: string; code: string;
}; };
@@ -29,14 +45,28 @@ export type GetDirectSignupByCodeReq = {
code: string; code: string;
}; };
export async function addDirectSignup(db: Database, _values: AddDirectSignupReq) { export async function addDirectSignup(db: Database, values: AddDirectSignupReq) {
const code = generateRandomString(16); const code = generateRandomString(16);
const uuid = values.uuid ?? null;
const comment = values.comment ?? null;
await db.insert(directSignup).values({ await db.insert(directSignup).values({
code: code code: code,
uuid: uuid,
comment: comment
}); });
return { code: code, url: siteUrl(`signup/${code}`) }; return { code: code, url: siteUrl(`signup/${code}`), uuid: uuid, comment: comment };
}
export async function editDirectSignup(db: Database, values: EditDirectSignupReq) {
await db
.update(directSignup)
.set({
uuid: values.uuid ?? null,
comment: values.comment ?? null
})
.where(eq(directSignup.code, values.code));
} }
export async function setDirectSignupUser(db: Database, values: SetDirectSignupUserReq) { export async function setDirectSignupUser(db: Database, values: SetDirectSignupUserReq) {
@@ -48,6 +78,15 @@ export async function setDirectSignupUser(db: Database, values: SetDirectSignupU
.where(eq(directSignup.code, values.code)); .where(eq(directSignup.code, values.code));
} }
export async function linkDirectSignupUserByUuid(db: Database, values: LinkDirectSignupUserByUuidReq) {
await db
.update(directSignup)
.set({
userId: values.userId
})
.where(and(eq(directSignup.uuid, values.uuid), isNull(directSignup.userId)));
}
export async function deleteDirectSignup(db: Database, values: DeleteDirectSignupReq) { export async function deleteDirectSignup(db: Database, values: DeleteDirectSignupReq) {
return db.delete(directSignup).where(eq(directSignup.code, values.code)); return db.delete(directSignup).where(eq(directSignup.code, values.code));
} }
@@ -56,7 +95,13 @@ export async function getDirectSignups(db: Database, _values: GetDirectSignupsRe
const directSignups = await db const directSignups = await db
.select({ .select({
code: directSignup.code, code: directSignup.code,
user: user uuid: directSignup.uuid,
comment: directSignup.comment,
user: {
id: user.id,
uuid: user.uuid,
username: user.username
}
}) })
.from(directSignup) .from(directSignup)
.leftJoin(user, eq(directSignup.userId, user.id)); .leftJoin(user, eq(directSignup.userId, user.id));
-66
View File
@@ -1,66 +0,0 @@
import { int, mysqlTable, text, varchar } from 'drizzle-orm/mysql-core';
import type { MySql2Database } from 'drizzle-orm/mysql2';
import { eq } from 'drizzle-orm';
import { user } from '@db/schema/user.ts';
type Database = MySql2Database<{ whitelistUser: typeof whitelistUser }>;
export const whitelistUser = mysqlTable('whitelist_user', {
uuid: varchar('uuid', { length: 36 }).primaryKey(),
comment: text('comment'),
userId: int('user_id')
.unique()
.references(() => user.id, { onDelete: 'set null' })
});
export type AddWhitelistUserReq = {
uuid: string;
comment?: string | null;
userId?: number | null;
};
export type EditWhitelistUserReq = {
uuid: string;
comment?: string | null;
userId?: number | null;
};
export type DeleteWhitelistUserReq = {
uuid: string;
};
export type GetWhitelistUsersReq = {};
export async function addWhitelistUser(db: Database, values: AddWhitelistUserReq) {
await db.insert(whitelistUser).values(values);
return { uuid: values.uuid };
}
export async function editWhitelistUser(db: Database, values: EditWhitelistUserReq) {
await db
.update(whitelistUser)
.set({
comment: values.comment,
userId: values.userId
})
.where(eq(whitelistUser.uuid, values.uuid));
}
export async function deleteWhitelistUser(db: Database, values: DeleteWhitelistUserReq) {
return db.delete(whitelistUser).where(eq(whitelistUser.uuid, values.uuid));
}
export async function getWhitelistUsers(db: Database, _values: GetWhitelistUsersReq) {
return db
.select({
uuid: whitelistUser.uuid,
comment: whitelistUser.comment,
user: {
id: user.id,
uuid: user.uuid
}
})
.from(whitelistUser)
.leftJoin(user, eq(whitelistUser.userId, user.id));
}
+2
View File
@@ -45,6 +45,8 @@ export const POST = externalApi({
if (input.code) { if (input.code) {
await tx.setDirectSignupUser({ code: input.code, userId: user.id }); await tx.setDirectSignupUser({ code: input.code, userId: user.id });
} }
await tx.linkDirectSignupUserByUuid({ uuid: input.uuid, userId: user.id });
}); });
return new Response(null, { status: 201 }); return new Response(null, { status: 201 });
+1 -1
View File
@@ -4,7 +4,7 @@ import { db } from '@db/database.ts';
export const GET = externalApi({ export const GET = externalApi({
auth: true, auth: true,
handler: async () => { handler: async () => {
const whitelist = await db.getWhitelistUsers({}); const whitelist = (await db.getDirectSignups({})).filter((entry) => entry.uuid != null);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({