feat(voiceover): add voice script and submission collections with profile XP
Add VoiceScripts and VoiceSubmissions collections, voiceover event types and event-log targets, the voice-submissions migration, and a voiceover XP group on Profiles counted toward total XP. Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
13f2132e25
commit
f83301a923
10 changed files with 23440 additions and 1 deletions
|
|
@ -32,6 +32,8 @@ const TARGET_COLLECTIONS = [
|
|||
{ label: "Wiki Templates", value: "wiki-templates" },
|
||||
{ label: "Game Servers", value: "game-servers" },
|
||||
{ label: "Assignment Transfers", value: "assignment-transfers" },
|
||||
{ label: "Voice Scripts", value: "voice-scripts" },
|
||||
{ label: "Voice Submissions", value: "voice-submissions" },
|
||||
] as const;
|
||||
|
||||
export const GameEventLogs: CollectionConfig = {
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ export const Profiles: CollectionConfig = {
|
|||
radioTraffic?: { xpEarned?: number | null };
|
||||
aimTrainer?: { xpEarned?: number | null };
|
||||
};
|
||||
voiceover?: { xpEarned?: number | null };
|
||||
};
|
||||
const awardsList = progressionData?.awards || [];
|
||||
const awardIds = awardsList.flatMap((entry) => {
|
||||
|
|
@ -145,6 +146,7 @@ export const Profiles: CollectionConfig = {
|
|||
const eodXp = progressionData?.minigames?.eodMinesweeper?.xpEarned ?? 0;
|
||||
const radioTrafficXp = progressionData?.minigames?.radioTraffic?.xpEarned ?? 0;
|
||||
const aimTrainerXp = progressionData?.minigames?.aimTrainer?.xpEarned ?? 0;
|
||||
const voiceoverXp = progressionData?.voiceover?.xpEarned ?? 0;
|
||||
|
||||
if (awardIds.length > 0) {
|
||||
await req.payload
|
||||
|
|
@ -186,6 +188,8 @@ export const Profiles: CollectionConfig = {
|
|||
xpAcc += eodXp;
|
||||
xpAcc += radioTrafficXp;
|
||||
xpAcc += aimTrainerXp;
|
||||
// Voiceover submissions: XP awarded per accepted submission
|
||||
xpAcc += voiceoverXp;
|
||||
|
||||
return xpAcc;
|
||||
},
|
||||
|
|
@ -563,6 +567,19 @@ export const Profiles: CollectionConfig = {
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "voiceover",
|
||||
label: "Voiceover",
|
||||
type: "group",
|
||||
fields: [
|
||||
{
|
||||
name: "xpEarned",
|
||||
label: "XP Earned",
|
||||
type: "number",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
57
src/collections/voiceover/VoiceScripts.ts
Normal file
57
src/collections/voiceover/VoiceScripts.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { CollectionConfig } from "payload";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
|
||||
export const VoiceScripts: CollectionConfig = {
|
||||
slug: "voice-scripts",
|
||||
admin: {
|
||||
group: "Voiceover",
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "xpAmount", "status", "createdAt"],
|
||||
},
|
||||
access: {
|
||||
read: ({ req }) => Boolean(req.user),
|
||||
create: ({ req }) => hasRoles(["admin", "developer"], req.user),
|
||||
update: ({ req }) => hasRoles(["admin", "developer"], req.user),
|
||||
delete: ({ req }) => hasRoles(["admin", "developer"], req.user),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "title",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "script",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
admin: {
|
||||
description:
|
||||
"Voice talent reads this verbatim. Inline emotion tags like [calm] are stored as written.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "xpAmount",
|
||||
label: "XP Amount",
|
||||
type: "number",
|
||||
required: true,
|
||||
defaultValue: 250,
|
||||
min: 0,
|
||||
admin: {
|
||||
description: "XP awarded per accepted submission of this script.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
required: true,
|
||||
defaultValue: "open",
|
||||
options: [
|
||||
{ label: "Open", value: "open" },
|
||||
{ label: "Closed", value: "closed" },
|
||||
],
|
||||
admin: {
|
||||
description: "Closed scripts reject new submissions",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
78
src/collections/voiceover/VoiceSubmissions.ts
Normal file
78
src/collections/voiceover/VoiceSubmissions.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { CollectionConfig } from "payload";
|
||||
import type { User } from "@/payload-types";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
|
||||
export const VoiceSubmissions: CollectionConfig = {
|
||||
slug: "voice-submissions",
|
||||
admin: {
|
||||
group: "Voiceover",
|
||||
useAsTitle: "id",
|
||||
defaultColumns: ["script", "user", "status", "createdAt"],
|
||||
},
|
||||
access: {
|
||||
read: async ({ req }) => {
|
||||
const user = req.user as User | null;
|
||||
if (!user) return false;
|
||||
// Elevation bypasses the owner scope so reviewers see every submission;
|
||||
// regular users only ever see their own.
|
||||
if (await isSuperuser(req.payload, user)) return true;
|
||||
if (hasRoles(["admin", "developer"], user)) return true;
|
||||
return { user: { equals: user.id } };
|
||||
},
|
||||
create: ({ req, data }) => {
|
||||
const user = req.user as User | null;
|
||||
if (!user) return false;
|
||||
if (hasRoles(["admin", "developer"], user)) return true;
|
||||
// Submitters may only create their own submissions. Server actions that
|
||||
// write on behalf of users run with overrideAccess and set `user` themselves.
|
||||
const submittedUser =
|
||||
typeof data?.user === "object" && data?.user !== null ? data.user.id : data?.user;
|
||||
return submittedUser === user.id;
|
||||
},
|
||||
// Status changes are review actions — submitters must not edit their own docs.
|
||||
update: ({ req }) => hasRoles(["admin", "developer"], req.user),
|
||||
delete: ({ req }) => hasRoles(["developer"], req.user),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "script",
|
||||
type: "relationship",
|
||||
relationTo: "voice-scripts",
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: "user",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: "Submitter",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
required: true,
|
||||
defaultValue: "pending",
|
||||
options: [
|
||||
{ label: "Pending", value: "pending" },
|
||||
{ label: "Accepted", value: "accepted" },
|
||||
{ label: "Rejected", value: "rejected" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "reviewNote",
|
||||
type: "textarea",
|
||||
admin: {
|
||||
description: "Optional admin review note.",
|
||||
},
|
||||
},
|
||||
],
|
||||
upload: {
|
||||
mimeTypes: ["audio/webm", "audio/mpeg", "audio/wav", "audio/ogg", "audio/mp4", "audio/x-m4a", "audio/m4a"],
|
||||
},
|
||||
timestamps: true,
|
||||
};
|
||||
23189
src/migrations/20260906_202925_add_voice_submissions.json
Normal file
23189
src/migrations/20260906_202925_add_voice_submissions.json
Normal file
File diff suppressed because it is too large
Load diff
77
src/migrations/20260906_202925_add_voice_submissions.ts
Normal file
77
src/migrations/20260906_202925_add_voice_submissions.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_voice_scripts_status" AS ENUM('open', 'closed');
|
||||
CREATE TYPE "public"."enum_voice_submissions_status" AS ENUM('pending', 'accepted', 'rejected');
|
||||
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'voice-scripts';
|
||||
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'voice-submissions';
|
||||
CREATE TABLE "voice_scripts" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"title" varchar NOT NULL,
|
||||
"script" varchar NOT NULL,
|
||||
"xp_amount" numeric DEFAULT 250 NOT NULL,
|
||||
"status" "enum_voice_scripts_status" DEFAULT 'open' NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "voice_submissions" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"script_id" integer NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"status" "enum_voice_submissions_status" DEFAULT 'pending' NOT NULL,
|
||||
"review_note" varchar,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"url" varchar,
|
||||
"thumbnail_u_r_l" varchar,
|
||||
"filename" varchar,
|
||||
"mime_type" varchar,
|
||||
"filesize" numeric,
|
||||
"width" numeric,
|
||||
"height" numeric,
|
||||
"focal_x" numeric,
|
||||
"focal_y" numeric
|
||||
);
|
||||
|
||||
ALTER TABLE "profiles" ADD COLUMN "progression_voiceover_xp_earned" numeric DEFAULT 0;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "voice_scripts_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "voice_submissions_id" integer;
|
||||
ALTER TABLE "voice_submissions" ADD CONSTRAINT "voice_submissions_script_id_voice_scripts_id_fk" FOREIGN KEY ("script_id") REFERENCES "public"."voice_scripts"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "voice_submissions" ADD CONSTRAINT "voice_submissions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
CREATE INDEX "voice_scripts_updated_at_idx" ON "voice_scripts" USING btree ("updated_at");
|
||||
CREATE INDEX "voice_scripts_created_at_idx" ON "voice_scripts" USING btree ("created_at");
|
||||
CREATE INDEX "voice_submissions_script_idx" ON "voice_submissions" USING btree ("script_id");
|
||||
CREATE INDEX "voice_submissions_user_idx" ON "voice_submissions" USING btree ("user_id");
|
||||
CREATE INDEX "voice_submissions_updated_at_idx" ON "voice_submissions" USING btree ("updated_at");
|
||||
CREATE INDEX "voice_submissions_created_at_idx" ON "voice_submissions" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "voice_submissions_filename_idx" ON "voice_submissions" USING btree ("filename");
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_voice_scripts_fk" FOREIGN KEY ("voice_scripts_id") REFERENCES "public"."voice_scripts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_voice_submissions_fk" FOREIGN KEY ("voice_submissions_id") REFERENCES "public"."voice_submissions"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "payload_locked_documents_rels_voice_scripts_id_idx" ON "payload_locked_documents_rels" USING btree ("voice_scripts_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_voice_submissions_id_idx" ON "payload_locked_documents_rels" USING btree ("voice_submissions_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "voice_scripts" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "voice_submissions" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "voice_scripts" CASCADE;
|
||||
DROP TABLE "voice_submissions" CASCADE;
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_voice_scripts_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_voice_submissions_fk";
|
||||
|
||||
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE text;
|
||||
DROP TYPE "public"."enum_game_event_logs_target_collection";
|
||||
CREATE TYPE "public"."enum_game_event_logs_target_collection" AS ENUM('game-structures', 'game-vehicles', 'game-npcs', 'npc-staffing', 'structures', 'resources', 'assets', 'vehicles', 'factions', 'missions', 'campaigns', 'users', 'technologies', 'maps', 'shipments', 'bank-accounts', 'bank-transactions', 'ledger-entries', 'locker-storages', 'loadouts', 'market-listings', 'market-negotiations', 'market-state', 'evaluations', 'tickets', 'wiki-pages', 'wiki-revisions', 'wiki-templates', 'game-servers', 'assignment-transfers');
|
||||
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE "public"."enum_game_event_logs_target_collection" USING "target_collection"::"public"."enum_game_event_logs_target_collection";
|
||||
DROP INDEX "payload_locked_documents_rels_voice_scripts_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_voice_submissions_id_idx";
|
||||
ALTER TABLE "profiles" DROP COLUMN "progression_voiceover_xp_earned";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "voice_scripts_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "voice_submissions_id";
|
||||
DROP TYPE "public"."enum_voice_scripts_status";
|
||||
DROP TYPE "public"."enum_voice_submissions_status";`)
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ import * as migration_20260906_063753_add_personal_excerpt_richtext from './2026
|
|||
import * as migration_20260906_065405_add_wiki_template_html from './20260906_065405_add_wiki_template_html';
|
||||
import * as migration_20260906_100908_add_market_economy_state from './20260906_100908_add_market_economy_state';
|
||||
import * as migration_20260906_165837_add_market_state_event_target from './20260906_165837_add_market_state_event_target';
|
||||
import * as migration_20260906_202925_add_voice_submissions from './20260906_202925_add_voice_submissions';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -216,4 +217,9 @@ export const migrations = [
|
|||
down: migration_20260906_165837_add_market_state_event_target.down,
|
||||
name: '20260906_165837_add_market_state_event_target',
|
||||
},
|
||||
{
|
||||
up: migration_20260906_202925_add_voice_submissions.up,
|
||||
down: migration_20260906_202925_add_voice_submissions.down,
|
||||
name: '20260906_202925_add_voice_submissions'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ import { Loadouts } from "@/collections/locker/Loadouts";
|
|||
import { MarketListings } from "@/collections/market/MarketListings";
|
||||
import { MarketNegotiations } from "@/collections/market/MarketNegotiations";
|
||||
import { MarketState } from "@/collections/market/MarketState";
|
||||
import { VoiceScripts } from "@/collections/voiceover/VoiceScripts";
|
||||
import { VoiceSubmissions } from "@/collections/voiceover/VoiceSubmissions";
|
||||
import { UserNotifications } from "@/collections/users/UserNotifications";
|
||||
import { Evaluations } from "@/collections/users/Evaluations";
|
||||
import { Tickets } from "@/collections/tickets/Tickets";
|
||||
|
|
@ -199,6 +201,10 @@ const collections = [
|
|||
MarketNegotiations,
|
||||
MarketState,
|
||||
|
||||
// Voiceover
|
||||
VoiceScripts,
|
||||
VoiceSubmissions,
|
||||
|
||||
// Helpdesk
|
||||
Tickets,
|
||||
TicketVotes,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ interface GameEventInput {
|
|||
| "wiki-templates"
|
||||
| "game-servers"
|
||||
| "assignment-transfers"
|
||||
| "market-state";
|
||||
| "market-state"
|
||||
| "voice-scripts"
|
||||
| "voice-submissions";
|
||||
targetId?: number;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,11 @@ export const EventTypes = {
|
|||
MinigameRadioTraffic: "minigame:radio-traffic",
|
||||
MinigameAimTrainer: "minigame:aim-trainer",
|
||||
|
||||
// Voiceover
|
||||
VoiceoverSubmit: "voiceover:submit",
|
||||
VoiceoverAccepted: "voiceover:accepted",
|
||||
VoiceoverRejected: "voiceover:rejected",
|
||||
|
||||
// Market
|
||||
MarketListingCreate: "market:listing-create",
|
||||
MarketListingCancel: "market:listing-cancel",
|
||||
|
|
|
|||
Loading…
Reference in a new issue