Add player bug reports and profile identity features

This commit is contained in:
2026-10-08 18:27:20 +05:00
parent 36e8d11eb4
commit 0b08672f4a
51 changed files with 3938 additions and 45 deletions

View File

@@ -1,4 +1,5 @@
import { z } from "zod";
import { glowColorSchema } from "./profile-appearance";
export const displayNameSchema = z
.string()
@@ -38,8 +39,15 @@ export const authActionSchema = z.discriminatedUnion("action", [
z.object({ action: z.literal("recover"), email }).strict(),
]);
export const profileUpdateSchema = z
.object({ displayName: displayNameSchema })
.strict();
.object({
displayName: displayNameSchema.optional(),
glowColor: glowColorSchema.optional(),
})
.strict()
.refine(
(value) => value.displayName !== undefined || value.glowColor !== undefined,
"Choose a profile change",
);
export const initialPasswordSchema = z
.object({ password: newPasswordSchema })
.strict();
@@ -61,10 +69,12 @@ export function authDestination(value: string | null | undefined): string {
value === "/settings" ||
value === "/dashboard" ||
value === "/servers" ||
value === "/support" ||
value === "/auth/reset-password"
)
return value;
if (value && /^\/dashboard\/[0-9a-f-]{36}$/i.test(value)) return value;
if (value && /^\/support\/[0-9a-f-]{36}$/i.test(value)) return value;
if (value && /^\/pair\?code=[A-F0-9]{8}$/.test(value)) return value;
return "/dashboard";
}

103
shared/bug-reports.ts Normal file
View File

@@ -0,0 +1,103 @@
import { z } from "zod";
import { displayNameSchema } from "./account";
export const screenshotLimit = 5 * 1024 * 1024;
const text = (min: number, max: number) =>
z
.string()
.trim()
.min(min)
.max(max)
.refine(
(value) =>
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(
value,
),
"Remove hidden control characters",
);
export const bugReportSchema = z
.object({
clientId: z.string().uuid(),
title: text(5, 120),
description: text(15, 6000),
displayName: displayNameSchema.optional(),
pagePath: z
.string()
.max(300)
.regex(/^\/(?!\/)[a-zA-Z0-9/_-]*$/)
.default("/"),
screenshot: z.boolean().default(false),
})
.strict();
export const bugMessageSchema = z
.object({ clientId: z.string().uuid(), text: text(1, 6000) })
.strict();
export type BugReportRecord = {
id: string;
client_id: string;
owner_id: string | null;
access_hash: string;
display_name: string;
title: string;
description: string;
page_path: string;
state: "draft" | "pending" | "open";
screenshot_expected: boolean;
screenshot_path: string | null;
linear_asset_url: string | null;
linear_identifier: string | null;
issue_synced: boolean;
glow_requested: boolean;
bug_confirmed?: boolean;
lease_owner: string | null;
lease_until: string | null;
retry_at: string | null;
created_at: string;
updated_at: string;
};
export type BugMessageRecord = {
id: string;
client_id: string | null;
report_id: string;
kind: "user" | "staff" | "system";
body: string;
author_name: string;
delivered: boolean;
created_at: string;
external_id: string | null;
};
export type BugReportView = Pick<
BugReportRecord,
| "id"
| "title"
| "description"
| "page_path"
| "state"
| "linear_identifier"
| "glow_requested"
| "created_at"
> & {
screenshot: string | null;
isGuest: boolean;
authorAppearance?: import("./profile-appearance").ProfileAppearance;
canClaim: boolean;
messages: Pick<
BugMessageRecord,
"id" | "kind" | "body" | "author_name" | "delivered" | "created_at"
>[];
};
export type SupportCommand =
| { kind: "reply"; text: string }
| { kind: "glow"; enabled: boolean };
export function supportCommand(body: string): SupportCommand | null {
const reply = /^#player-report-send\s+([\s\S]+)$/.exec(body.trim());
if (reply) {
const parsed = text(1, 6000).safeParse(reply[1]);
return parsed.success ? { kind: "reply", text: parsed.data } : null;
}
const glow = /^#player-report-glow (grant|revoke)$/.exec(body.trim());
return glow ? { kind: "glow", enabled: glow[1] === "grant" } : null;
}
export function escapeSupportMarkdown(value: string) {
return value.replace(/[\\`*_{}\[\]()<>#!|~]/g, "\\$&");
}

View File

@@ -43,6 +43,30 @@ export type Database = {
};
public: {
Tables: {
bug_reports: {
Row: import("./bug-reports").BugReportRecord;
Insert: Partial<import("./bug-reports").BugReportRecord> &
Pick<
import("./bug-reports").BugReportRecord,
| "client_id"
| "access_hash"
| "display_name"
| "title"
| "description"
>;
Update: Partial<import("./bug-reports").BugReportRecord>;
Relationships: [];
};
bug_report_messages: {
Row: import("./bug-reports").BugMessageRecord;
Insert: Partial<import("./bug-reports").BugMessageRecord> &
Pick<
import("./bug-reports").BugMessageRecord,
"report_id" | "kind" | "body" | "author_name"
>;
Update: Partial<import("./bug-reports").BugMessageRecord>;
Relationships: [];
};
multiplayer_messages: {
Row: {
id: number;
@@ -115,6 +139,7 @@ export type Database = {
};
multiplayer_players: {
Row: {
account_id: string | null;
id: string;
room_id: string;
access_hash: string;
@@ -124,6 +149,7 @@ export type Database = {
joined_at: string;
};
Insert: {
account_id?: string | null;
id?: string;
room_id: string;
access_hash: string;
@@ -132,7 +158,11 @@ export type Database = {
has_left?: boolean;
joined_at?: string;
};
Update: { ready_round?: number; has_left?: boolean };
Update: {
ready_round?: number;
has_left?: boolean;
account_id?: string | null;
};
Relationships: [];
};
multiplayer_guesses: {
@@ -453,18 +483,33 @@ export type Database = {
};
profiles: {
Row: {
glow_color: string;
bug_finder: boolean;
custom_prefix: string | null;
custom_prefix_icon: string;
name_glow: boolean;
created_at: string;
display_name: string;
id: string;
role: string;
};
Insert: {
glow_color?: string;
bug_finder?: boolean;
custom_prefix?: string | null;
custom_prefix_icon?: string;
name_glow?: boolean;
created_at?: string;
display_name?: string;
id: string;
role?: string;
};
Update: {
glow_color?: string;
bug_finder?: boolean;
custom_prefix?: string | null;
custom_prefix_icon?: string;
name_glow?: boolean;
created_at?: string;
display_name?: string;
id?: string;
@@ -650,6 +695,33 @@ export type Database = {
[_ in never]: never;
};
Functions: {
set_user_prefix: {
Args: {
p_reviewer: string;
p_user: string;
p_prefix: string | null;
p_icon: string;
};
Returns: undefined;
};
claim_bug_sync: {
Args: { p_report: string; p_worker: string };
Returns: boolean;
};
receive_bug_reply: {
Args: {
p_report: string;
p_external: string;
p_author: string;
p_body: string;
p_glow: boolean | null;
};
Returns: boolean;
};
claim_bug_report: {
Args: { p_report: string; p_hash: string; p_owner: string };
Returns: undefined;
};
create_map_import: {
Args: {
p_owner: string;

View File

@@ -5,14 +5,16 @@ export const leaderboardDifficulties = [
"hard",
] as const;
export type LeaderboardDifficulty = (typeof leaderboardDifficulties)[number];
export type LeaderboardEntry = {
rank: number;
player_id: string;
display_name: string;
best_score: number;
average_score: number;
games_played: number;
};
export type LeaderboardEntry =
import("./profile-appearance").ProfileAppearance & {
rank: number;
player_id: string;
display_name: string;
name_glow?: boolean;
best_score: number;
average_score: number;
games_played: number;
};
export type LeaderboardData = {
entries: LeaderboardEntry[];
leaders: LeaderboardEntry[];

View File

@@ -714,5 +714,77 @@
"Chat permission requires joining this room": "Чтобы пользоваться чатом, войдите в эту комнату",
"Chat is unavailable in a closed or expired room": "Чат недоступен в закрытой комнате или комнате с истёкшим сроком",
"Too many chat messages. Wait a moment and try again.": "Слишком много сообщений. Подождите немного и попробуйте снова.",
"Invalid chat message": "Недопустимое сообщение чата"
"Invalid chat message": "Недопустимое сообщение чата",
"Send bug report": "Сообщить об ошибке",
"Report a bug": "Сообщить об ошибке",
"Moderator": "Модератор",
"Bug finder": "Нашёл ошибку",
"Username glow": "Свечение имени",
"Choose the color of your glow.": "Выберите цвет свечения вашего имени.",
"Support can award a glow for a confirmed bug report.": "Поддержка может подарить свечение за подтверждённую ошибку.",
"Glow color": "Цвет свечения",
"Save glow color": "Сохранить цвет свечения",
"Glow color saved.": "Цвет свечения сохранён.",
"Earn a username glow before choosing its color": "Сначала получите свечение имени, чтобы выбрать его цвет",
"Choose a valid glow color": "Выберите допустимый цвет свечения",
"Player prefixes": "Префиксы игроков",
"Find a player and assign a custom prefix with an icon.": "Найдите игрока и выдайте ему свой префикс с иконкой.",
"Player name or account ID": "Имя игрока или ID аккаунта",
"Find players": "Найти игроков",
"No players found.": "Игроки не найдены.",
"Custom prefix": "Свой префикс",
"Prefix icon": "Иконка префикса",
"Save prefix": "Сохранить префикс",
"Remove prefix": "Убрать префикс",
"Player prefix saved.": "Префикс игрока сохранён.",
"Use a prefix of 1–24 characters without hidden controls or markup": "Используйте от 1 до 24 символов без скрытых управляющих символов и разметки",
"Star": "Звезда",
"Crown": "Корона",
"Heart": "Сердце",
"Flame": "Огонь",
"Gem": "Самоцвет",
"Trophy": "Кубок",
"Sparkles": "Искры",
"Player not found": "Игрок не найден",
"PLAYER SUPPORT": "ПОДДЕРЖКА ИГРОКОВ",
"Found a bug?": "Нашли ошибку?",
"Tell us what happened. We’ll reply here.": "Расскажите, что произошло. Мы ответим здесь.",
"Guest reports stay in this browser. Sign in to keep your conversation in your account.": "Обращения гостей сохраняются в этом браузере. Войдите, чтобы сохранить переписку в аккаунте.",
"Bug reporting is temporarily unavailable. Please try again later.": "Отправка обращений временно недоступна. Попробуйте позже.",
"Your player name": "Ваше имя игрока",
"Bug title": "Название ошибки",
"What happened?": "Что произошло?",
"What did you do, what did you expect, and what happened instead?": "Что вы сделали, чего ожидали и что произошло вместо этого?",
"Screenshot (optional)": "Скриншот (необязательно)",
"PNG, JPG, or WebP, up to 5 MB. Shared privately with support.": "PNG, JPG или WebP, до 5 МБ. Доступен только вам и поддержке.",
"Your bug reports": "Ваши обращения",
"Your reports and support replies will appear here.": "Здесь появятся ваши обращения и ответы поддержки.",
"Draft": "Черновик",
"Waiting for support": "Ожидает поддержки",
"← Your bug reports": "← Ваши обращения",
"Your report is saved. We’re connecting it to support.": "Обращение сохранено. Передаём его поддержке.",
"This report is not sent yet. Return to the form or finish sending it.": "Обращение ещё не отправлено. Вернитесь к форме или завершите отправку.",
"Finish sending report": "Завершить отправку",
"Keep this report in my account": "Сохранить обращение в аккаунте",
"Support awarded you a username glow.": "Поддержка подарила вам свечение имени.",
"Account settings": "Настройки аккаунта",
"For guest reports, sign in and link this report to receive your reward.": "Если вы отправили обращение как гость, войдите и привяжите его к аккаунту, чтобы получить награду.",
"Attached bug screenshot": "Прикреплённый скриншот ошибки",
"Support conversation": "Переписка с поддержкой",
"Support": "Поддержка",
"Delivered to support": "Доставлено поддержке",
"Saved · waiting to send": "Сохранено · ожидает отправки",
"Message support": "Написать поддержке",
"Username glow granted.": "Свечение имени выдано.",
"Username glow removed.": "Свечение имени отключено.",
"Add a short title, at least 15 characters describing the bug, and your player name.": "Добавьте короткое название, описание ошибки от 15 символов и своё имя игрока.",
"Choose a PNG, JPG, or WebP screenshot smaller than 5 MB": "Выберите скриншот PNG, JPG или WebP размером до 5 МБ",
"Screenshot upload failed. Please try again.": "Не удалось загрузить скриншот. Попробуйте ещё раз.",
"Use 1–6000 characters without hidden controls": "Используйте от 1 до 6000 символов без скрытых управляющих символов",
"Open support before sending a report": "Откройте страницу поддержки перед отправкой обращения",
"Report not found": "Обращение не найдено",
"Submit this report before sending messages": "Отправьте обращение перед отправкой сообщений",
"Invalid support message": "Недопустимое сообщение поддержке",
"Sign in to link this report": "Войдите, чтобы привязать обращение",
"Remove hidden control characters": "Удалите скрытые управляющие символы"
}

View File

@@ -27,6 +27,7 @@ export type RoomChat = {
id: string;
playerId: string;
name: string;
appearance?: import("./profile-appearance").ProfileAppearance;
text: string;
createdAt: string;
}[];
@@ -62,6 +63,7 @@ export type RoomView = {
players: {
id: string;
name: string;
appearance?: import("./profile-appearance").ProfileAppearance;
left: boolean;
ready: boolean;
submitted: boolean;

View File

@@ -0,0 +1,46 @@
import { z } from "zod";
export const defaultGlowColor = "#ffe6a1";
export const glowColorSchema = z
.string()
.regex(/^#[a-fA-F0-9]{6}$/, "Choose a valid glow color")
.transform((color) => color.toLowerCase());
export const prefixIcons = [
"star",
"crown",
"heart",
"flame",
"gem",
"trophy",
"sparkles",
] as const;
export type PrefixIcon = (typeof prefixIcons)[number];
export const customPrefixSchema = z
.string()
.trim()
.min(1)
.max(24)
.refine(
(value) =>
!/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069<>]/u.test(value),
"Use a prefix of 1–24 characters without hidden controls or markup",
);
export const prefixUpdateSchema = z
.object({ prefix: customPrefixSchema.nullable(), icon: z.enum(prefixIcons) })
.strict();
export type ProfileAppearance = {
name_glow?: boolean;
glow_color?: string;
role?: string;
bug_finder?: boolean;
custom_prefix?: string | null;
custom_prefix_icon?: string;
};
export const appearanceColumns =
"name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon" as const;
export type BadgeUser = ProfileAppearance & {
id: string;
display_name: string;
};
export function glowColor(value?: string) {
return glowColorSchema.safeParse(value).data ?? defaultGlowColor;
}