1
0
Fork 0

Compare commits

...

7 commits

32 changed files with 22273 additions and 85 deletions

View file

@ -23,3 +23,5 @@ DISCORD_ATTENDANCE_POLL_MS=5000
DISCORD_NOTIFICATION_POLL_MS=5000 DISCORD_NOTIFICATION_POLL_MS=5000
UNSLOTH_API_KEY=sk-unsloth-12a9650c5c90a5b544bfd45786be0516 UNSLOTH_API_KEY=sk-unsloth-12a9650c5c90a5b544bfd45786be0516
ARMA_BRIDGE_API_KEY=polaristaskforce

1
.gitignore vendored
View file

@ -51,6 +51,7 @@ next-env.d.ts
.env.* .env.*
/media /media
/backups/
# Playwright # Playwright
node_modules/ node_modules/

View file

@ -2,8 +2,10 @@
## Full Feature Additions, Top Priority ## Full Feature Additions, Top Priority
- [ ] **Leadership evaluations and performance rating system** - Members of the unit should be able to rate the performance of their direct team leaders (full performance rating) and submit opinions on separate leaders (a more limited rating system for non-direct supervisors.) These ratings should appear anonymously on the leader's service record page. The ratings are divided by mission, so that they can accumulate over time to form a more accurate representation of the overall delta of a leader's performance over the course of multiple operations. These ratings appear as bars, red-to-green gradients, and percentage scores on the service record page. A small table showing the latest missions and the leader's overall rating are also visible below this bar. Additionally, leaders should be able to rate the performance of their subordinates. However, these ratings appear in a far more limited fashion on the subordinate's service record page. Instead of a breakdown with bars, percentages, latest missions, etc., they instead just get a text label indicating a summary of their performance from leadership. For example, if a subordinate is rated very highly by their leaders, their text label might say "OPERATOR PERFORMANCE LEVEL: EXCELLENT" in green text while a moderate review may say "OPERATOR PERFORMANCE LEVEL: MODERATE" in grey or blue text and a low rating might say "OPERATOR PERFORMANCE LEVEL: UNDER REVIEW" in red text. Preferably, there'd be at least 5 levels of performance instead of the 3 I used as examples. - [x] **Leadership evaluations and performance rating system** - Members of the unit should be able to rate the performance of their direct team leaders (full performance rating) and submit opinions on separate leaders (a more limited rating system for non-direct supervisors.) These ratings should appear anonymously on the leader's service record page. The ratings are divided by mission, so that they can accumulate over time to form a more accurate representation of the overall delta of a leader's performance over the course of multiple operations. These ratings appear as bars, red-to-green gradients, and percentage scores on the service record page. A small table showing the latest missions and the leader's overall rating are also visible below this bar. Additionally, leaders should be able to rate the performance of their subordinates. However, these ratings appear in a far more limited fashion on the subordinate's service record page. Instead of a breakdown with bars, percentages, latest missions, etc., they instead just get a text label indicating a summary of their performance from leadership. For example, if a subordinate is rated very highly by their leaders, their text label might say "OPERATOR PERFORMANCE LEVEL: EXCELLENT" in green text while a moderate review may say "OPERATOR PERFORMANCE LEVEL: MODERATE" in grey or blue text and a low rating might say "OPERATOR PERFORMANCE LEVEL: UNDER REVIEW" in red text. Preferably, there'd be at least 5 levels of performance instead of the 3 I used as examples.
- [ ] **Ribbon overview and listing page** - There should be a page that lists all of the medals and ribbons that could be granted to a user (excluding commendations, which could arise on the fly and are not set in stone.) This would allow users to review the requirements for earning a specific medal or ribbon, and get to see the art of that award as well, if any. - [ ] **Ribbon overview and listing page** - There should be a page that lists all of the medals and ribbons that could be granted to a user (excluding commendations, which could arise on the fly and are not set in stone.) This would allow users to review the requirements for earning a specific medal or ribbon, and get to see the art of that award as well, if any.
- [ ] **Assignment transfer request system** - Allow users to request transfers to new assignments (i.e. transfer from infantry to aviation, medical to infantry, medical to aviation, infantry to intel, logistics to infantry, etc., covering all cases). The leader of the user's current assignment and the leader of the user's requested assignment should be notified of the request via app and via bot, and should have the option to "approve" or "reject with reason" in either interface. If both leaders approve, the assignment is automatically transferred and updated everywhere. If one leader rejects it, the rejection reason is sent to the other leader and to the individual making the request, and the individual is given an option to appeal via the app or the bot. If they appeal, both leaders are bypassed and the request goes directly to me (or a superuser) who can then make the final call.
- [ ] **Scoped admin UI pages** - The admin UI is fantastic, but it needs to be scoped so that users can only see and interact with certain pages even if they have read permissions for any/all pages. For example, if a user has read permissions for the Users collection, they can't just see the admin page for it - they need something like "admin:users:manage" or something like that (based on whatever format fits our current system best.) So long as they have BOTH the read and manage permissions, they can see the admin page. Otherwise, it's just hidden from them entirely.
- [ ] **Editable helpdesk tickets & user voting** - Having helpdesk tickets be editable by their authors after the fact would be very helpful. This would have to be auditable, of course. In addition, allow users to upvote specific tickets would help to prioritize desired features, pressing bugs, or other tickets wherein there is a shared desire for work from the userbase. - [ ] **Editable helpdesk tickets & user voting** - Having helpdesk tickets be editable by their authors after the fact would be very helpful. This would have to be auditable, of course. In addition, allow users to upvote specific tickets would help to prioritize desired features, pressing bugs, or other tickets wherein there is a shared desire for work from the userbase.
- [ ] **"Community" navigation group** - A "Community" nav group in the sidebar with subpages like "Contacts (NPCs)", "Statistics", etc., would be really helpful for future updates wherein the players will have interactions with NPCs via the market, in general conversation, long-standing faction interactions, etc. - [ ] **"Community" navigation group** - A "Community" nav group in the sidebar with subpages like "Contacts (NPCs)", "Statistics", etc., would be really helpful for future updates wherein the players will have interactions with NPCs via the market, in general conversation, long-standing faction interactions, etc.

506
scripts/sync-prod.sh Executable file
View file

@ -0,0 +1,506 @@
#!/usr/bin/env bash
#
# sync-prod.sh — Sync production data to dev or staging.
#
# Dumps the production database (ptf_app_prd) and restores it into the target
# environment's database. Optionally syncs media uploads via SSH. Backs up the
# target database and media before overwriting (one backup retained).
#
# Usage:
# ./scripts/sync-prod.sh [OPTIONS]
#
# Examples:
# ./scripts/sync-prod.sh # sync DB + media → dev
# ./scripts/sync-prod.sh --target stg # sync DB + media → staging
# ./scripts/sync-prod.sh --dry-run # preview without writing
# ./scripts/sync-prod.sh --skip-media # DB only, no media sync
# ./scripts/sync-prod.sh --backup-only # backup current target state
# ./scripts/sync-prod.sh --list-backups # show available backups
set -euo pipefail
# ─── Configuration ────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
BACKUP_DIR="${PROJECT_ROOT}/backups"
# Environment targets
declare -A ENV_CONFIG=(
[dev]=".env"
[stg]=".env.stg"
)
# Defaults
TARGET="dev"
DRY_RUN=false
AUTO_YES=false
SKIP_MEDIA=false
BACKUP_ONLY=false
LIST_BACKUPS=false
NO_MIGRATE=false
SOURCE_FILE="${PROJECT_ROOT}/.env.prod"
# Media sync defaults — override via env or CLI flags
MEDIA_HOST="${MEDIA_HOST:-192.168.1.4}"
MEDIA_USER="${MEDIA_USER:-root}"
MEDIA_PATH="${MEDIA_PATH:-/app/media}"
PROD_CONTAINER="${PROD_CONTAINER:-polaris-task-force-main-website}"
# ─── Helpers ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
die() { err "$@"; exit 1; }
usage() {
cat <<'EOF'
sync-prod.sh — Sync production data to dev or staging databases.
USAGE
./scripts/sync-prod.sh [OPTIONS]
OPTIONS
--target, -t DEV|STG Target environment (default: dev)
--dry-run, -n Show what would happen without making changes
--yes, -y Skip confirmation prompts
--skip-media Sync database only, skip media uploads
--no-migrate Skip running pending Payload migrations after restore
--backup-only Backup target state without syncing
--list-backups List available backups
--source FILE Production env file (default: .env.prod)
--media-host HOST Media server hostname (default: 192.168.1.4)
--media-user USER SSH user for media server (default: root)
--media-path PATH Remote media directory (default: /app/media)
--prod-container NAME Production Docker container name
--help, -h Show this help
ENVIRONMENT VARIABLES
These can be set in .env.prod or exported in your shell:
PROD_DATABASE_URI Production PostgreSQL connection string (required)
PROD_HOST Production server hostname (for SSH media sync)
PROD_USER SSH user for production server
MEDIA_HOST Media server hostname (default: 192.168.1.4)
MEDIA_USER SSH user for media server (default: root)
MEDIA_PATH Remote media path (default: /app/media)
SETUP
1. Copy .env.prod.example to .env.prod and fill in production credentials.
2. Ensure SSH key access to the production server (for media sync).
3. Run: ./scripts/sync-prod.sh
WHAT IT DOES
Database:
- Dumps production DB (ptf_app_prd) via pg_dump
- Backs up the target database before restore
- Restores into the target environment's database
- Runs pending Payload migrations (keeps schema in sync with dev code)
- Resets all sequences to prevent ID collisions
Media (unless --skip-media):
- Backs up current media directory
- Streams /app/media from the production Docker container via SSH
- Extracts to local media directory
Safety:
- Confirmation prompt before any changes (skip with --yes)
- Creates timestamped backups before overwriting
- --dry-run shows what would happen without writing
- Backups stored in backups/ (gitignored)
BACKUPS
./scripts/sync-prod.sh --list-backups # show available backups
backups/<timestamp>/ # DB dump + media archive
backups/current-media-backup -> latest # symlink to most recent
EOF
}
# ─── Parse arguments ──────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--target|-t) TARGET="$2"; shift 2 ;;
--dry-run|-n) DRY_RUN=true; shift ;;
--yes|-y) AUTO_YES=true; shift ;;
--skip-media) SKIP_MEDIA=true; shift ;;
--no-migrate) NO_MIGRATE=true; shift ;;
--backup-only) BACKUP_ONLY=true; shift ;;
--list-backups) LIST_BACKUPS=true; shift ;;
--source) SOURCE_FILE="$2"; shift 2 ;;
--media-host) MEDIA_HOST="$2"; shift 2 ;;
--media-user) MEDIA_USER="$2"; shift 2 ;;
--media-path) MEDIA_PATH="$2"; shift 2 ;;
--prod-container) PROD_CONTAINER="$2"; shift 2 ;;
--help|-h) usage; exit 0 ;;
*) die "Unknown option: $1 (use --help for usage)" ;;
esac
done
# ─── Load environment ─────────────────────────────────────────────────────────
load_env() {
local src="$1"
[[ -f "$src" ]] || die "Source file not found: $src
Create it from the template:
cp .env.prod.example .env.prod
# Edit .env.prod with your production credentials"
set -a
# shellcheck disable=SC1090
source "$src"
set +a
}
# ─── Backup helpers ───────────────────────────────────────────────────────────
TS=""
BACKUP_PATH=""
MEDIA_BACKUP_PATH=""
setup_backup_dirs() {
BACKUP_PATH="${BACKUP_DIR}/${TS}"
mkdir -p "${BACKUP_PATH}/db"
mkdir -p "${BACKUP_PATH}/media"
ok "Backup directory: ${BACKUP_PATH}"
}
backup_target_db() {
info "Backing up target database (${TARGET_DB_NAME})..."
if $DRY_RUN; then
info "[DRY RUN] Would dump target database to ${BACKUP_PATH}/db/"
return 0
fi
if ! PGPASSWORD="$TARGET_DB_PASS" pg_dump \
-h "$TARGET_DB_HOST" \
-p "$TARGET_DB_PORT" \
-U "$TARGET_DB_USER" \
-d "$TARGET_DB_NAME" \
--no-owner --no-privileges --no-acl \
-Fc \
-f "${BACKUP_PATH}/db/${TARGET}.dump" 2>&1; then
warn "Target database backup failed — continuing anyway (target may be empty)"
return 0
fi
ok "Target database backed up → ${BACKUP_PATH}/db/${TARGET}.dump"
}
backup_media() {
info "Backing up current media..."
if $DRY_RUN; then
info "[DRY RUN] Would backup media to ${BACKUP_PATH}/media/"
return 0
fi
if [[ ! -d "$PROJECT_ROOT/media" ]]; then
warn "No local media directory to backup"
return 0
fi
if ! tar -czf "${BACKUP_PATH}/media/media.tar.gz" \
-C "$PROJECT_ROOT" media 2>&1; then
warn "Media backup failed — continuing anyway"
return 0
fi
MEDIA_BACKUP_PATH="${BACKUP_PATH}/media/media.tar.gz"
# Update the "current" symlink
rm -f "${BACKUP_DIR}/current-media-backup"
ln -s "$BACKUP_PATH" "${BACKUP_DIR}/current-media-backup"
ok "Media backed up → ${BACKUP_PATH}/media/media.tar.gz"
}
# ─── Database sync ────────────────────────────────────────────────────────────
sync_database() {
info "Syncing database: ${PROD_DB_NAME} → ${TARGET_DB_NAME}"
info " Source: ${PROD_DB_HOST}:${PROD_DB_PORT}"
info " Target: ${TARGET_DB_HOST}:${TARGET_DB_PORT}"
if $DRY_RUN; then
info "[DRY RUN] Would dump production database and restore to target"
return 0
fi
info "Dumping production database..."
local dump_file="${BACKUP_PATH}/db/prod.dump"
if ! PGPASSWORD="$PROD_DB_PASS" pg_dump \
-h "$PROD_DB_HOST" \
-p "$PROD_DB_PORT" \
-U "$PROD_DB_USER" \
-d "$PROD_DB_NAME" \
--no-owner --no-privileges --no-acl \
-Fc \
-f "$dump_file" 2>&1; then
die "pg_dump failed. Check production database credentials and connectivity."
fi
local dump_size
dump_size=$(du -h "$dump_file" | cut -f1)
ok "Production dump complete (${dump_size})"
info "Recreating target database..."
if ! PGPASSWORD="$TARGET_DB_PASS" psql \
-h "$TARGET_DB_HOST" \
-p "$TARGET_DB_PORT" \
-U "$TARGET_DB_USER" \
-d postgres \
-c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${TARGET_DB_NAME}' AND pid <> pg_backend_pid();" \
-c "DROP DATABASE IF EXISTS \"${TARGET_DB_NAME}\" WITH (FORCE);" \
-c "CREATE DATABASE \"${TARGET_DB_NAME}\" OWNER \"${TARGET_DB_USER}\";" 2>&1; then
die "Failed to drop/recreate target database. Check target database permissions."
fi
info "Restoring into target database..."
local restore_exit=0
PGPASSWORD="$TARGET_DB_PASS" pg_restore \
-h "$TARGET_DB_HOST" \
-p "$TARGET_DB_PORT" \
-U "$TARGET_DB_USER" \
-d "$TARGET_DB_NAME" \
--no-owner --no-privileges --no-acl \
--if-exists --clean \
"$dump_file" 2>&1 || restore_exit=$?
if [[ $restore_exit -gt 1 ]]; then
die "pg_restore failed (exit code ${restore_exit}). Check error output above."
fi
if [[ $restore_exit -eq 1 ]]; then
warn "pg_restore completed with warnings (exit code 1) — this is usually fine."
fi
if ! $NO_MIGRATE; then
info "Running pending migrations..."
if ! DATABASE_URI="$TARGET_DB_URI" bun run payload migrate 2>&1; then
warn "Migration step failed — data is restored but schema may be behind dev"
else
ok "Migrations applied"
fi
fi
info "Resetting sequences..."
PGPASSWORD="$TARGET_DB_PASS" psql \
-h "$TARGET_DB_HOST" \
-p "$TARGET_DB_PORT" \
-U "$TARGET_DB_USER" \
-d "$TARGET_DB_NAME" \
-c "SELECT setval(pg_get_serial_sequence(t.table_name, c.column_name), (SELECT COALESCE(MAX(c.column_name::text::bigint), 1) FROM information_schema.columns c JOIN information_schema.tables t ON c.table_name = t.table_name WHERE c.column_name = 'id')) FROM information_schema.columns c JOIN information_schema.tables t ON c.table_name = t.table_name WHERE c.column_name = 'id' AND c.column_default LIKE 'nextval%' AND t.table_schema = 'public';" 2>/dev/null || true
ok "Database sync complete: ${TARGET_DB_NAME}"
}
# ─── Media sync ───────────────────────────────────────────────────────────────
sync_media() {
if $SKIP_MEDIA; then
info "Skipping media sync (--skip-media)"
return 0
fi
# Determine SSH target
local ssh_host="${MEDIA_HOST:-${PROD_HOST:-}}"
local ssh_user="${MEDIA_USER:-${PROD_USER:-root}}"
local container="$PROD_CONTAINER"
[[ -n "$ssh_host" ]] || die "No media server host. Set MEDIA_HOST or PROD_HOST."
info "Syncing media from ${ssh_user}@${ssh_host}:${MEDIA_PATH}"
if $DRY_RUN; then
info "[DRY RUN] Would stream /app/media from ${container} via SSH"
return 0
fi
# Create local media directory if it doesn't exist
mkdir -p "$PROJECT_ROOT/media"
# Stream media from production container via SSH
# Using docker exec + tar to read from inside the container (works with
# Docker volumes regardless of mount path on the host)
info "Streaming media from production container..."
if ! ssh -o ConnectTimeout=10 "${ssh_user}@${ssh_host}" \
"docker exec ${container} tar -cf - -C /app media" \
| tar -xf - -C "$PROJECT_ROOT"; then
die "Media sync failed. Check SSH access to ${ssh_host} and container '${container}' exists."
fi
ok "Media sync complete → ${PROJECT_ROOT}/media/"
}
# ─── Summary ──────────────────────────────────────────────────────────────────
print_summary() {
local elapsed=$1
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if $DRY_RUN; then
echo -e " ${YELLOW}DRY RUN COMPLETE${NC} — nothing was changed"
else
echo -e " ${GREEN}SYNC COMPLETE${NC}"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Target: ${TARGET} (${TARGET_DB_NAME})"
echo " Source: production (${PROD_DB_NAME})"
if [[ -n "$BACKUP_PATH" ]] && [[ -d "$BACKUP_PATH" ]]; then
echo " Backup: ${BACKUP_PATH}"
fi
echo " Duration: ${elapsed}s"
echo ""
echo " To rollback:"
echo " Database: pg_restore -h $TARGET_DB_HOST -p $TARGET_DB_PORT -U $TARGET_DB_USER -d $TARGET_DB_NAME --clean --if-exists ${BACKUP_PATH}/db/${TARGET}.dump"
echo " Media: tar -xf ${BACKUP_PATH}/media/media.tar.gz -C ${PROJECT_ROOT}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
}
# ─── Backup listing ───────────────────────────────────────────────────────────
list_backups() {
echo "Available backups:"
echo ""
if [[ ! -d "$BACKUP_DIR" ]] || [[ -z "$(ls -A "$BACKUP_DIR" 2>/dev/null)" ]]; then
echo " No backups found."
return 0
fi
for dir in "${BACKUP_DIR}"/*/; do
[[ -d "$dir" ]] || continue
local name
name=$(basename "$dir")
local db_size="—"
local media_size="—"
if [[ -f "${dir}db/${TARGET:-dev}.dump" ]]; then
db_size=$(du -h "${dir}db/${TARGET:-dev}.dump" | cut -f1)
fi
if [[ -f "${dir}media/media.tar.gz" ]]; then
media_size=$(du -h "${dir}media/media.tar.gz" | cut -f1)
fi
printf " %-20s DB: %-8s Media: %s\n" "$name" "$db_size" "$media_size"
done
}
# ─── Main ─────────────────────────────────────────────────────────────────────
main() {
if $LIST_BACKUPS; then
list_backups
exit 0
fi
# Load environment (not needed for --list-backups or --help)
load_env "$SOURCE_FILE"
# Parse production database URI
PROD_DB_USER=""
PROD_DB_PASS=""
PROD_DB_HOST=""
PROD_DB_PORT=""
PROD_DB_NAME=""
if [[ -n "${PROD_DATABASE_URI:-}" ]]; then
PROD_DB_USER=$(echo "$PROD_DATABASE_URI" | sed -n 's|.*://\([^:]*\):.*|\1|p')
PROD_DB_PASS=$(echo "$PROD_DATABASE_URI" | sed -n 's|.*://[^:]*:\([^@]*\)@.*|\1|p')
PROD_DB_HOST=$(echo "$PROD_DATABASE_URI" | sed -n 's|.*@\([^:]*\):.*|\1|p')
PROD_DB_PORT=$(echo "$PROD_DATABASE_URI" | sed -n 's|.*:\([0-9]*\)/.*|\1|p')
PROD_DB_NAME=$(echo "$PROD_DATABASE_URI" | sed -n 's|.*/\([^?]*\).*|\1|p')
fi
# Validate required production config
[[ -n "$PROD_DB_HOST" ]] || die "PROD_DATABASE_URI not set or missing host in $SOURCE_FILE"
[[ -n "$PROD_DB_PORT" ]] || die "PROD_DATABASE_URI not set or missing port in $SOURCE_FILE"
[[ -n "$PROD_DB_NAME" ]] || die "PROD_DATABASE_URI not set or missing database name in $SOURCE_FILE"
[[ -n "$PROD_DB_USER" ]] || die "PROD_DATABASE_URI not set or missing user in $SOURCE_FILE"
[[ -n "$PROD_DB_PASS" ]] || die "PROD_DATABASE_URI not set or missing password in $SOURCE_FILE"
# Validate target
[[ -n "${ENV_CONFIG[$TARGET]:-}" ]] || die "Invalid target: $TARGET (must be: dev, stg)"
# Check PostgreSQL tools
for cmd in pg_dump pg_restore psql; do
command -v "$cmd" >/dev/null 2>&1 || die "$cmd not found. Install postgresql-client."
done
# Resolve target database URI
TARGET_ENV_FILE="${PROJECT_ROOT}/${ENV_CONFIG[$TARGET]}"
[[ -f "$TARGET_ENV_FILE" ]] || die "Target env file not found: $TARGET_ENV_FILE"
TARGET_DB_URI=$(grep -E '^DATABASE_URI=' "$TARGET_ENV_FILE" | head -1 | cut -d'=' -f2-)
[[ -n "$TARGET_DB_URI" ]] || die "DATABASE_URI not found in $TARGET_ENV_FILE"
TARGET_DB_USER=$(echo "$TARGET_DB_URI" | sed -n 's|.*://\([^:]*\):.*|\1|p')
TARGET_DB_PASS=$(echo "$TARGET_DB_URI" | sed -n 's|.*://[^:]*:\([^@]*\)@.*|\1|p')
TARGET_DB_HOST=$(echo "$TARGET_DB_URI" | sed -n 's|.*@\([^:]*\):.*|\1|p')
TARGET_DB_PORT=$(echo "$TARGET_DB_URI" | sed -n 's|.*:\([0-9]*\)/.*|\1|p')
TARGET_DB_NAME=$(echo "$TARGET_DB_URI" | sed -n 's|.*/\([^?]*\).*|\1|p')
TARGET_DB_HOST="${PROD_HOST:-$TARGET_DB_HOST}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Polaris Task Force — Production Data Sync"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo " Source: ${PROD_DB_NAME} @ ${PROD_DB_HOST}:${PROD_DB_PORT}"
echo " Target: ${TARGET_DB_NAME} @ ${TARGET_DB_HOST}:${TARGET_DB_PORT}"
if ! $SKIP_MEDIA; then
echo " Media: ${MEDIA_HOST:-${PROD_HOST:-?}} → local media/"
else
echo " Media: skipped"
fi
if $DRY_RUN; then
echo -e " Mode: ${YELLOW}DRY RUN${NC}"
fi
echo ""
# Confirmation prompt
if ! $AUTO_YES && ! $DRY_RUN; then
echo -e " ${RED}WARNING: This will OVERWRITE the ${TARGET} database and media.${NC}"
echo ""
read -rp " Continue? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
echo ""
fi
local start_time
start_time=$(date +%s)
TS=$(date +%Y%m%d_%H%M%S)
if $BACKUP_ONLY; then
setup_backup_dirs
backup_target_db
backup_media
else
setup_backup_dirs
backup_target_db
backup_media
sync_database
sync_media
fi
local end_time elapsed
end_time=$(date +%s)
elapsed=$((end_time - start_time))
print_summary "$elapsed"
}
main "$@"

View file

@ -0,0 +1,159 @@
"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { isSuperuser } from "@/utils/access-control/hasPermission";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import {
countGrantsByGrantor,
findUserProfile,
getGrantorAssignments,
isRecipientEligible,
} from "@/lib/awards/grant";
import type { Profile } from "@/payload-types";
interface ActionResult<T = undefined> {
success: boolean;
error?: string;
data?: T;
}
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({
headers: hdrs,
canSetHeaders: false,
});
if (!isPayloadUser(user)) {
throw new Error("Unauthorized");
}
return { payload, user };
}
export async function grantAward(input: {
recipientUserId: number;
awardId: number;
reason?: string;
}): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
let award;
try {
award = await payload.findByID({
collection: "awards",
id: input.awardId,
depth: 0,
overrideAccess: true,
});
} catch {
return { success: false, error: "Award not found." };
}
let grantorProfile: Profile | null = null;
const superuser = await isSuperuser(payload, user);
if (award.type === "medal") {
if (!superuser) {
return { success: false, error: "Only superusers can grant medals." };
}
} else if (!superuser) {
const allowedTypes = award.grantConfig?.allowedGrantorAssignmentTypes ?? [];
if (allowedTypes.length === 0) {
return { success: false, error: "This award cannot be granted by leaders." };
}
const grantorAssignments = await getGrantorAssignments(
payload,
user.id as number,
allowedTypes,
);
if (grantorAssignments.length === 0) {
return { success: false, error: "You are not authorized to grant this award." };
}
const grantorAssignmentId = grantorAssignments[0].id;
const maxGrants = award.grantConfig?.maxGrantsPerGrantor ?? null;
if (maxGrants) {
grantorProfile = await findUserProfile(payload, user.id as number);
if (!grantorProfile) {
return { success: false, error: "You do not have a profile." };
}
const issued = await countGrantsByGrantor(payload, award.id, grantorProfile.id);
if (issued >= maxGrants) {
return {
success: false,
error: "You have reached the maximum number of grants for this award.",
};
}
}
const eligible = await isRecipientEligible(
payload,
input.recipientUserId,
award.grantConfig?.recipientScope ?? "same_command",
grantorAssignmentId,
award.grantConfig?.eligibleRecipientAssignments,
);
if (!eligible) {
return { success: false, error: "Recipient is not eligible for this award." };
}
}
const recipientProfile = await findUserProfile(payload, input.recipientUserId);
if (!recipientProfile) {
return { success: false, error: "Recipient does not have a profile." };
}
if (!grantorProfile) {
grantorProfile = await findUserProfile(payload, user.id as number);
}
if (!grantorProfile) {
return { success: false, error: "You do not have a profile." };
}
const progression = recipientProfile.progression;
const currentAwards = progression?.awards ?? [];
await payload.update({
collection: "profiles",
id: recipientProfile.id,
data: {
progression: {
...progression,
awards: [
...currentAwards,
{
award: award.id,
awardedBy: grantorProfile.id,
awardedAt: new Date().toISOString(),
awardReason: input.reason ?? null,
},
],
},
},
overrideAccess: true,
});
await emitGameEvent(payload, {
type: EventTypes.AwardGrant,
message: `Granted "${award.name}" to recipient profile #${recipientProfile.id}`,
actor: user.id,
data: { awardId: award.id, grantorProfileId: grantorProfile.id },
});
return { success: true };
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
}
}

View file

@ -0,0 +1,105 @@
"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { isSuperuser } from "@/utils/access-control/hasPermission";
import { countGrantsByGrantor, findUserProfile, getGrantorAssignments } from "@/lib/awards/grant";
/**
* Read-only grant context for a single award: whether the current user may
* grant it and how many grants (if limited) they have left. Mirrors the
* authorization logic in `grantAward` so the UI can warn before submitting.
*/
export interface GrantStats {
canGrant: boolean;
isSuperuser: boolean;
currentGrants: number;
maxGrants: number | null;
remainingGrants: number | null;
}
interface ActionResult<T = undefined> {
success: boolean;
error?: string;
data?: T;
}
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({
headers: hdrs,
canSetHeaders: false,
});
if (!isPayloadUser(user)) {
throw new Error("Unauthorized");
}
return { payload, user };
}
export async function getGrantStats(awardId: number): Promise<ActionResult<GrantStats>> {
try {
const { payload, user } = await authenticate();
let award;
try {
award = await payload.findByID({
collection: "awards",
id: awardId,
depth: 0,
overrideAccess: true,
});
} catch {
return { success: false, error: "Award not found." };
}
const superuser = await isSuperuser(payload, user);
let canGrant = false;
if (superuser) {
canGrant = true;
} else if (award.type === "medal") {
canGrant = false;
} else {
const allowedTypes = award.grantConfig?.allowedGrantorAssignmentTypes ?? [];
if (allowedTypes.length > 0) {
const grantorAssignments = await getGrantorAssignments(
payload,
user.id as number,
allowedTypes,
);
canGrant = grantorAssignments.length > 0;
}
}
let currentGrants = 0;
const maxGrants = superuser ? null : (award.grantConfig?.maxGrantsPerGrantor ?? null);
if (canGrant && maxGrants) {
const grantorProfile = await findUserProfile(payload, user.id as number);
if (grantorProfile) {
currentGrants = await countGrantsByGrantor(payload, awardId, grantorProfile.id);
}
}
return {
success: true,
data: {
canGrant,
isSuperuser: superuser,
currentGrants,
maxGrants,
remainingGrants: maxGrants === null ? null : Math.max(maxGrants - currentGrants, 0),
},
};
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
}
}

View file

@ -0,0 +1,86 @@
import config from "@payload-config";
import { getPayload } from "payload";
import type { Award, Media } from "@/payload-types";
import { AwardWithGrantButton } from "@/components/frontend/account/AwardWithGrantButton";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { MedalIcon } from "lucide-react";
export const metadata = {
title: "Awards",
description:
"Browse every medal and ribbon that can be granted to a member of Polaris Task Force.",
};
const TYPE_LABEL: Record<"ribbon" | "medal", string> = {
ribbon: "Ribbons",
medal: "Medals",
};
export default async function AwardsPage() {
const payload = await getPayload({ config });
const res = await payload.find({
collection: "awards",
where: {
type: { in: ["ribbon", "medal"] },
},
sort: "name",
limit: 100,
depth: 1,
overrideAccess: true,
});
const awards: Award[] = res.docs;
const ribbons = awards.filter((a) => a.type === "ribbon");
const medals = awards.filter((a) => a.type === "medal");
const sections: { title: string; items: Award[] }[] = [];
if (medals.length > 0) sections.push({ title: TYPE_LABEL.medal, items: medals });
if (ribbons.length > 0) sections.push({ title: TYPE_LABEL.ribbon, items: ribbons });
return (
<div className="p-5 flex flex-col gap-6 max-w-7xl">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<MedalIcon className="size-4 text-muted-foreground" />
<h1 className="text-2xl font-bold tracking-tight uppercase">Awards</h1>
</div>
<p className="text-sm text-muted-foreground max-w-2xl">
A reference of every medal and ribbon that can be earned by a member of Polaris Task
Force. Review the requirements for each award and its artwork. Commendations are granted
on the fly and are not catalogued here.
</p>
</div>
{awards.length === 0 ? (
<Item variant="muted">
<ItemHeader>
<ItemTitle>No awards defined</ItemTitle>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground italic">
No medals or ribbons have been catalogued yet. Check back soon.
</p>
</ItemContent>
</Item>
) : (
sections.map((section) => (
<section key={section.title} className="flex flex-col gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{section.title}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{section.items.map((award) => {
const awardImage =
typeof award.image === "object" && award.image ? (award.image as Media) : null;
return (
<AwardWithGrantButton key={award.id} award={award} awardImage={awardImage} />
);
})}
</div>
</section>
))
)}
</div>
);
}

View file

@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import type { Faction, Media, Mission } from "@/payload-types"; import type { Faction, Media, Mission } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react"; import { RichText } from "@payloadcms/richtext-lexical/react";
import { ClientDate } from "@/components/frontend/ClientDate";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
CalendarDaysIcon, CalendarDaysIcon,
@ -104,21 +105,6 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
const missions = missionsRes.docs as Mission[]; const missions = missionsRes.docs as Mission[];
const factions = factionsRes.docs as Faction[]; const factions = factionsRes.docs as Faction[];
const startDate = campaign.startDate
? new Date(campaign.startDate).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})
: null;
const endDate = campaign.endDate
? new Date(campaign.endDate).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})
: null;
// Mission status breakdown // Mission status breakdown
const statusCounts: Record<string, number> = {}; const statusCounts: Record<string, number> = {};
for (const m of missions) { for (const m of missions) {
@ -169,12 +155,28 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
{campaign.name} {campaign.name}
</h1> </h1>
<p className="mt-3 max-w-3xl text-lg leading-relaxed text-white/80">{campaign.summary}</p> <p className="mt-3 max-w-3xl text-lg leading-relaxed text-white/80">{campaign.summary}</p>
{(startDate || endDate) && ( {(campaign.startDate || campaign.endDate) && (
<div className="mt-6 flex items-center gap-2 text-sm text-white/70"> <div className="mt-6 flex items-center gap-2 text-sm text-white/70">
<CalendarDaysIcon className="size-4 text-white/60" /> <CalendarDaysIcon className="size-4 text-white/60" />
{startDate && <span>{startDate}</span>} {campaign.startDate && (
{startDate && endDate && <span>—</span>} <span>
{endDate ? <span>{endDate}</span> : startDate ? <span>Present</span> : null} <ClientDate
date={campaign.startDate}
options={{ year: "numeric", month: "short", day: "numeric" }}
/>
</span>
)}
{campaign.startDate && campaign.endDate && <span>—</span>}
{campaign.endDate ? (
<span>
<ClientDate
date={campaign.endDate}
options={{ year: "numeric", month: "short", day: "numeric" }}
/>
</span>
) : campaign.startDate ? (
<span>Present</span>
) : null}
</div> </div>
)} )}
</div> </div>
@ -257,8 +259,23 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
<div className="flex justify-between text-sm pt-1 border-t border-border/50"> <div className="flex justify-between text-sm pt-1 border-t border-border/50">
<span className="text-muted-foreground">Duration</span> <span className="text-muted-foreground">Duration</span>
<span> <span>
{startDate} {campaign.startDate && (
{endDate ? ` — ${endDate}` : " — Present"} <ClientDate
date={campaign.startDate}
options={{ year: "numeric", month: "short", day: "numeric" }}
/>
)}
{campaign.endDate ? (
<>
{" — "}
<ClientDate
date={campaign.endDate}
options={{ year: "numeric", month: "short", day: "numeric" }}
/>
</>
) : (
" — Present"
)}
</span> </span>
</div> </div>
<div className="flex justify-between text-sm pt-1 border-t border-border/50"> <div className="flex justify-between text-sm pt-1 border-t border-border/50">

View file

@ -15,6 +15,7 @@ import {
ServerIcon, ServerIcon,
} from "lucide-react"; } from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { ClientDate } from "@/components/frontend/ClientDate";
import { MissionBriefingSection } from "@/components/frontend/intelligence/MissionBriefingSection"; import { MissionBriefingSection } from "@/components/frontend/intelligence/MissionBriefingSection";
import { MissionObjectives } from "@/components/frontend/intelligence/MissionObjectives"; import { MissionObjectives } from "@/components/frontend/intelligence/MissionObjectives";
import { hasPermission } from "@/utils/access-control/hasPermission"; import { hasPermission } from "@/utils/access-control/hasPermission";
@ -91,17 +92,6 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
: null; : null;
const coverImageUrl = coverImage?.url ?? null; const coverImageUrl = coverImage?.url ?? null;
const startDate = new Date(mission.classification.startDateTime);
const dateStr = startDate.toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
const timeStr = startDate.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
const durationHours = Math.floor(mission.classification.estimatedDuration / 60); const durationHours = Math.floor(mission.classification.estimatedDuration / 60);
const durationMins = mission.classification.estimatedDuration % 60; const durationMins = mission.classification.estimatedDuration % 60;
const durationStr = durationMins > 0 ? `${durationHours}h ${durationMins}m` : `${durationHours}h`; const durationStr = durationMins > 0 ? `${durationHours}h ${durationMins}m` : `${durationHours}h`;
@ -241,7 +231,12 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/50"> <span className="text-[10px] font-semibold uppercase tracking-wider text-white/50">
Date Date
</span> </span>
<span className="text-sm text-white">{dateStr}</span> <span className="text-sm text-white">
<ClientDate
date={mission.classification.startDateTime}
options={{ weekday: "long", year: "numeric", month: "long", day: "numeric" }}
/>
</span>
</span> </span>
</div> </div>
<div className="flex items-center gap-2 px-3 py-2"> <div className="flex items-center gap-2 px-3 py-2">
@ -251,7 +246,11 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
Time Time
</span> </span>
<span className="text-sm text-white"> <span className="text-sm text-white">
{timeStr} · {durationStr} <ClientDate
date={mission.classification.startDateTime}
options={{ hour: "2-digit", minute: "2-digit" }}
/>{" "}
· {durationStr}
</span> </span>
</span> </span>
</div> </div>

View file

@ -12,6 +12,7 @@ import {
TruckIcon, TruckIcon,
} from "lucide-react"; } from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { ClientDate } from "@/components/frontend/ClientDate";
import { ShipmentActions } from "@/components/frontend/logistics/ShipmentActions"; import { ShipmentActions } from "@/components/frontend/logistics/ShipmentActions";
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar"; import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
@ -308,11 +309,11 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Dispatched</span> <span className="text-muted-foreground">Dispatched</span>
<span> <span>
{dispatchedAt.toLocaleDateString()}{" "} <ClientDate date={shipment.dispatchedAt} options={{ dateStyle: "medium" }} />{" "}
{dispatchedAt.toLocaleTimeString([], { <ClientDate
hour: "2-digit", date={shipment.dispatchedAt}
minute: "2-digit", options={{ hour: "2-digit", minute: "2-digit" }}
})} />
</span> </span>
</div> </div>
)} )}
@ -322,11 +323,14 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
{shipment.status === "completed" ? "Arrived" : "Est. Arrival"} {shipment.status === "completed" ? "Arrived" : "Est. Arrival"}
</span> </span>
<span> <span>
{estimatedArrival.toLocaleDateString()}{" "} <ClientDate
{estimatedArrival.toLocaleTimeString([], { date={shipment.estimatedArrival}
hour: "2-digit", options={{ dateStyle: "medium" }}
minute: "2-digit", />{" "}
})} <ClientDate
date={shipment.estimatedArrival}
options={{ hour: "2-digit", minute: "2-digit" }}
/>
</span> </span>
</div> </div>
)} )}
@ -334,11 +338,11 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Completed</span> <span className="text-muted-foreground">Completed</span>
<span> <span>
{completedAt.toLocaleDateString()}{" "} <ClientDate date={shipment.completedAt} options={{ dateStyle: "medium" }} />{" "}
{completedAt.toLocaleTimeString([], { <ClientDate
hour: "2-digit", date={shipment.completedAt}
minute: "2-digit", options={{ hour: "2-digit", minute: "2-digit" }}
})} />
</span> </span>
</div> </div>
)} )}

View file

@ -3,8 +3,9 @@ import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import Link from "next/link"; import Link from "next/link";
import type { Assignment, Media, Qualification, Rank, User } from "@/payload-types"; import type { Award, Assignment, Media, Qualification, Rank, User } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react"; import { RichText } from "@payloadcms/richtext-lexical/react";
import { ClientDate } from "@/components/frontend/ClientDate";
import { resolveLevel } from "@/utils/xp/resolveLevel"; import { resolveLevel } from "@/utils/xp/resolveLevel";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { isSuperuser } from "@/utils/access-control/hasPermission"; import { isSuperuser } from "@/utils/access-control/hasPermission";
@ -39,6 +40,7 @@ import {
ItemTitle, ItemTitle,
} from "@/components/ui/item"; } from "@/components/ui/item";
import AwardCard from "@/components/frontend/account/AwardCard"; import AwardCard from "@/components/frontend/account/AwardCard";
import { ProfileGrantAwardButton } from "@/components/frontend/account/ProfileGrantAwardButton";
import { RibbonRack } from "@/components/frontend/account/RibbonRack"; import { RibbonRack } from "@/components/frontend/account/RibbonRack";
import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection"; import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection";
import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels"; import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels";
@ -282,6 +284,16 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
}); });
const assignments = assignmentRes.docs as Pick<Assignment, "name" | "type" | "callsign">[]; const assignments = assignmentRes.docs as Pick<Assignment, "name" | "type" | "callsign">[];
const grantableAwardsRes = await payload.find({
collection: "awards",
where: { type: { in: ["ribbon", "commendation"] } },
sort: "name",
limit: 100,
depth: 1,
overrideAccess: true,
});
const grantableAwards = grantableAwardsRes.docs as Award[];
const rankInsignia = const rankInsignia =
typeof rank === "object" && rank.insignia && typeof rank.insignia === "object" typeof rank === "object" && rank.insignia && typeof rank.insignia === "object"
? (rank.insignia as Media) ? (rank.insignia as Media)
@ -296,11 +308,6 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const kdRatio = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills > 0 ? "∞" : "—"; const kdRatio = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills > 0 ? "∞" : "—";
const enlistDate = new Date(profile.dossier.enlistmentDate); const enlistDate = new Date(profile.dossier.enlistmentDate);
const enlistDateStr = enlistDate.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
const now = new Date(); const now = new Date();
const serviceMonths = const serviceMonths =
(now.getFullYear() - enlistDate.getFullYear()) * 12 + (now.getMonth() - enlistDate.getMonth()); (now.getFullYear() - enlistDate.getFullYear()) * 12 + (now.getMonth() - enlistDate.getMonth());
@ -369,7 +376,12 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>Enlisted</ItemTitle> <ItemTitle>Enlisted</ItemTitle>
<ItemDescription>{enlistDateStr}</ItemDescription> <ItemDescription>
<ClientDate
date={profile.dossier.enlistmentDate}
options={{ year: "numeric", month: "short", day: "numeric" }}
/>
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
<Item variant="muted" size="sm"> <Item variant="muted" size="sm">
@ -499,6 +511,10 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
)} )}
</section> </section>
{grantableAwards.length > 0 && (
<ProfileGrantAwardButton userId={user.id} username={username} awards={grantableAwards} />
)}
{/* ── Combat Record ──────────────────────────────────── */} {/* ── Combat Record ──────────────────────────────────── */}
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

@ -7,6 +7,7 @@ import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1e
import { default as default_d9c22b951ac6762f436db59b889da382 } from '@/components/admin/awards/CommendationColorField' import { default as default_d9c22b951ac6762f436db59b889da382 } from '@/components/admin/awards/CommendationColorField'
import { default as default_c3ea68702b5e5f8f2f98055e01c43c8d } from '@/components/admin/awards/RibbonDesigner' import { default as default_c3ea68702b5e5f8f2f98055e01c43c8d } from '@/components/admin/awards/RibbonDesigner'
import { default as default_2f2033acccb3be245f1a85f7b4271dac } from '@/components/admin/awards/MedalDesigner' import { default as default_2f2033acccb3be245f1a85f7b4271dac } from '@/components/admin/awards/MedalDesigner'
import { default as default_4b88a624e9f0b376cffcc6814d459adb } from '@/components/admin/assignments/AssignmentMembersField'
import { default as default_0edde40a6ccd30c893b1227360f69360 } from '@/components/static/TickDurationDescription' import { default as default_0edde40a6ccd30c893b1227360f69360 } from '@/components/static/TickDurationDescription'
import { default as default_2d468cc5cf0b647ffca3201592c20825 } from '@/components/static/WarningMessage' import { default as default_2d468cc5cf0b647ffca3201592c20825 } from '@/components/static/WarningMessage'
import { default as default_7fa52484aec46e988e9cfb5e86c0c759 } from '@/components/admin/narrative-flow/NarrativeFlowEditor' import { default as default_7fa52484aec46e988e9cfb5e86c0c759 } from '@/components/admin/narrative-flow/NarrativeFlowEditor'
@ -32,6 +33,7 @@ export const importMap = {
"@/components/admin/awards/CommendationColorField#default": default_d9c22b951ac6762f436db59b889da382, "@/components/admin/awards/CommendationColorField#default": default_d9c22b951ac6762f436db59b889da382,
"@/components/admin/awards/RibbonDesigner#default": default_c3ea68702b5e5f8f2f98055e01c43c8d, "@/components/admin/awards/RibbonDesigner#default": default_c3ea68702b5e5f8f2f98055e01c43c8d,
"@/components/admin/awards/MedalDesigner#default": default_2f2033acccb3be245f1a85f7b4271dac, "@/components/admin/awards/MedalDesigner#default": default_2f2033acccb3be245f1a85f7b4271dac,
"@/components/admin/assignments/AssignmentMembersField#default": default_4b88a624e9f0b376cffcc6814d459adb,
"@/components/static/TickDurationDescription#default": default_0edde40a6ccd30c893b1227360f69360, "@/components/static/TickDurationDescription#default": default_0edde40a6ccd30c893b1227360f69360,
"@/components/static/WarningMessage#default": default_2d468cc5cf0b647ffca3201592c20825, "@/components/static/WarningMessage#default": default_2d468cc5cf0b647ffca3201592c20825,
"@/components/admin/narrative-flow/NarrativeFlowEditor#default": default_7fa52484aec46e988e9cfb5e86c0c759, "@/components/admin/narrative-flow/NarrativeFlowEditor#default": default_7fa52484aec46e988e9cfb5e86c0c759,

View file

@ -88,6 +88,9 @@ export const Assignments: CollectionConfig = {
relationTo: "users", relationTo: "users",
hasMany: true, hasMany: true,
admin: { admin: {
components: {
Field: "@/components/admin/assignments/AssignmentMembersField",
},
description: "Add individuals as members of this team/assignment.", description: "Add individuals as members of this team/assignment.",
}, },
}, },

View file

@ -84,5 +84,64 @@ export const Awards: CollectionConfig = {
condition: (data) => data?.type === "medal", condition: (data) => data?.type === "medal",
}, },
}, },
{
name: "grantConfig",
type: "group",
label: "Grant Permissions",
admin: {
description:
"Configure who can grant this award and to whom. Only applies to ribbons and commendations.",
condition: (data) => data?.type !== "medal",
},
fields: [
{
name: "allowedGrantorAssignmentTypes",
type: "select",
hasMany: true,
label: "Eligible Grantor Assignments",
options: [
{ label: "Division Leader", value: "division" },
{ label: "Squad Leader", value: "squad" },
{ label: "Team Leader", value: "team" },
{ label: "Special Assignment Leader", value: "special" },
],
admin: {
description:
"Leaders of these assignment types can grant this award. Leave empty for admin/developer only.",
},
},
{
name: "maxGrantsPerGrantor",
type: "number",
label: "Max Grants Per Leader",
min: 1,
admin: {
description: "Maximum times each grantor can issue this award. Empty = unlimited.",
},
},
{
name: "recipientScope",
type: "select",
label: "Recipient Eligibility",
defaultValue: "same_command",
options: [
{ label: "Own Command (grantor's assignment tree)", value: "same_command" },
{ label: "Whitelist (specific assignments)", value: "whitelist" },
{ label: "Anyone (unit-wide)", value: "anyone" },
],
},
{
name: "eligibleRecipientAssignments",
type: "relationship",
relationTo: "assignments",
hasMany: true,
label: "Eligible Recipient Assignments",
admin: {
condition: (data) => data?.grantConfig?.recipientScope === "whitelist",
description: "Only members of these assignments can receive this award.",
},
},
],
},
], ],
}; };

View file

@ -0,0 +1,231 @@
"use client";
import {
ConfirmationModal,
RelationshipInput,
toast,
useDocumentInfo,
useField,
useModal,
} from "@payloadcms/ui";
import type { RelationshipFieldClientComponent } from "payload";
import { useCallback, useId, useMemo, useState } from "react";
type ExistingAssignment = {
id: number;
name: string;
};
type PendingSelection = {
value: Array<{ relationTo: string; value: number | string }>;
conflicts: ExistingAssignment[];
};
const getId = (value: number | string | { value?: number | string }) =>
typeof value === "object" ? value.value : value;
/**
* Keeps Payload's relationship picker, but asks before putting an operator in
* more than one assignment. “Replace” updates existing assignments through
* the admin API; “Add as new” only changes this unsaved assignment form.
*/
const AssignmentMembersField: RelationshipFieldClientComponent = (props) => {
const { field, path: pathFromProps, readOnly, validate } = props;
const { id: documentId } = useDocumentInfo();
const { openModal } = useModal();
const modalSlug = useId().replaceAll(":", "");
const {
customComponents: { AfterInput, BeforeInput, Description, Error: ErrorComponent, Label } = {},
disabled,
filterOptions,
initialValue,
path,
setValue,
showError,
value: currentValue,
} = useField<Array<number | string>>({
potentiallyStalePath: pathFromProps,
// RelationshipInput performs the field validation itself. Its validation
// type is intentionally broader than useField's internal callback type.
});
const [pending, setPending] = useState<PendingSelection | null>(null);
const {
admin: {
allowCreate = true,
allowEdit = true,
appearance = "select",
className,
description,
isSortable = true,
placeholder,
sortOptions,
} = {},
hasMany,
label,
localized,
relationTo,
required,
} = field;
const relation = typeof relationTo === "string" ? relationTo : relationTo[0];
const selectedIds = useMemo(
() =>
Array.isArray(currentValue) ? currentValue.map((item) => getId(item)).filter(Boolean) : [],
[currentValue],
);
const applyValue = useCallback(
(next: Array<{ relationTo: string; value: number | string }>) => {
setValue(next.map((item) => item.value));
},
[setValue],
);
const handleChange = useCallback(
async (next: Array<{ relationTo: string; value: number | string }>) => {
const added = next.filter((item) => !selectedIds.includes(item.value));
if (added.length === 0) {
applyValue(next);
return;
}
const responses = await Promise.all(
added.map(async (item) => {
const query = new URLSearchParams({
depth: "0",
limit: "100",
"where[or][0][members][contains]": String(item.value),
"where[or][1][leader][equals]": String(item.value),
});
const response = await fetch(`/api/assignments?${query.toString()}`);
if (!response.ok) throw new Error("Could not check existing assignments.");
return (await response.json()).docs as ExistingAssignment[];
}),
);
const conflicts = responses.flat().filter((assignment, index, assignments) => {
if (String(assignment.id) === String(documentId)) return false;
return assignments.findIndex((item) => item.id === assignment.id) === index;
});
if (conflicts.length > 0) {
setPending({ value: next, conflicts });
openModal(modalSlug);
} else {
applyValue(next);
}
},
[applyValue, documentId, modalSlug, openModal, selectedIds],
);
const replaceAssignments = async () => {
if (!pending) return;
try {
for (const assignment of pending.conflicts) {
const response = await fetch(`/api/assignments/${assignment.id}?depth=0`);
if (!response.ok) throw new Error("Could not load an existing assignment.");
const existing = await response.json();
const members = (existing.members ?? [])
.map((member: number | { id?: number }) =>
typeof member === "object" ? member.id : member,
)
.filter(
(member: number | undefined) =>
member !== undefined &&
!pending.value.some((item) => String(item.value) === String(member)),
);
const leaderId =
typeof existing.leader === "object" ? existing.leader?.id : existing.leader;
const update = await fetch(`/api/assignments/${assignment.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
members,
...(pending.value.some((item) => String(item.value) === String(leaderId))
? { leader: null }
: {}),
}),
});
if (!update.ok) throw new Error(`Could not remove the operator from ${assignment.name}.`);
}
applyValue(pending.value);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Could not replace the existing assignment.",
);
}
setPending(null);
};
return (
<>
<RelationshipInput
AfterInput={AfterInput}
allowCreate={allowCreate}
allowEdit={allowEdit}
appearance={appearance}
BeforeInput={BeforeInput}
className={className}
Description={Description}
description={description}
Error={ErrorComponent}
filterOptions={filterOptions}
Label={Label}
label={label}
localized={localized}
maxResultsPerRequest={10}
isSortable={isSortable}
path={path}
placeholder={placeholder}
readOnly={readOnly || disabled}
relationTo={[relation]}
required={required}
showError={showError}
sortOptions={sortOptions}
hasMany={true}
initialValue={
Array.isArray(initialValue)
? initialValue.map((value) => ({ relationTo: relation, value }))
: undefined
}
value={
Array.isArray(currentValue)
? currentValue.map((value) => ({ relationTo: relation, value }))
: null
}
onChange={handleChange}
/>
<ConfirmationModal
body={
<>
<p>
This operator is already assigned to{" "}
{pending?.conflicts.length === 1 ? "an assignment" : "these assignments"}:
</p>
<ul>
{pending?.conflicts.map((assignment) => (
<li key={assignment.id}>{assignment.name}</li>
))}
</ul>
<p>
Replace the existing assignment, or add this operator to the new assignment as well?
</p>
</>
}
cancelLabel="Add as new"
confirmingLabel="Replacing..."
confirmLabel="Replace existing"
heading="Operator already assigned"
modalSlug={modalSlug}
onCancel={() => {
if (pending) applyValue(pending.value);
setPending(null);
}}
onConfirm={replaceAssignments}
/>
</>
);
};
export default AssignmentMembersField;

View file

@ -0,0 +1,32 @@
"use client";
/**
* Renders a date/time in the browser's local timezone.
*
* Server components cannot know the visitor's timezone — `Intl` methods there
* format using the server process's timezone (typically UTC in Docker/Node),
* producing a misleading multi-hour offset. This client component guarantees
* the value is formatted in the browser's local timezone instead, matching
* client-rendered lists (e.g. `MissionCard`).
*
* Usage:
* <ClientDate date={iso} options={{ dateStyle: "medium", timeStyle: "short" }} />
* <ClientDate date={iso} options={{ weekday: "long", month: "long", day: "numeric", year: "numeric" }} />
*
* Falls back to an em dash when `date` is missing/invalid.
*/
export function ClientDate({
date,
options,
locale,
}: {
date: string | null | undefined;
options?: Intl.DateTimeFormatOptions;
locale?: string;
}) {
if (!date) return <>&mdash;</>;
const parsed = new Date(date);
if (Number.isNaN(parsed.getTime())) return <>&mdash;</>;
const formatted = new Intl.DateTimeFormat(locale ?? undefined, options).format(parsed);
return <>{formatted}</>;
}

View file

@ -0,0 +1,132 @@
import { Award, Media } from "@/payload-types";
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { AwardIcon, LockIcon } from "lucide-react";
/**
* Card shown on the awards listing page. Unlike the profile's AwardCard
* (which wraps an individual's grant), this reviews a single definable award
* type — ribbon or medal — so users can inspect the award art and its
* full requirements. Commendations are intentionally out of scope.
*
* Ribbon art is rendered at a fixed, centered width so every ribbon (and its
* container) is an identical, justified size across the grid. Medal art is
* currently hidden; only the ribbon art is shown.
*/
interface AwardListingCardProps {
award: Award;
awardImage?: Media | null;
showGrantButton?: boolean;
onGrantClick?: () => void;
canGrant?: boolean | null;
remainingGrants?: number | null;
}
export function AwardListingCard({
award,
awardImage,
showGrantButton = false,
onGrantClick,
canGrant,
remainingGrants,
}: AwardListingCardProps) {
const type = award.type ?? "commendation";
const isMedal = type === "medal";
const hasRibbonArt =
type === "ribbon" &&
award.ribbonDesign &&
typeof award.ribbonDesign === "object" &&
Array.isArray((award.ribbonDesign as RibbonDesign).stripes);
const hasArt = hasRibbonArt || Boolean(awardImage?.url);
return (
<Item
variant="outline"
className={cn(
"min-w-0 h-full flex flex-col flex-nowrap text-center",
isMedal && "justify-center medal-card",
)}
>
<ItemHeader className={cn("flex flex-col items-center gap-2", isMedal && "justify-center")}>
{hasArt && (
<div
className={cn(
"flex w-full items-center justify-center",
hasRibbonArt ? "h-20" : "h-16",
)}
>
{hasRibbonArt ? (
<div className="w-44 max-w-full">
<RibbonViewer
ribbonDesign={award.ribbonDesign as RibbonDesign}
showRibbonOnly={false}
/>
</div>
) : awardImage?.url ? (
<img src={awardImage.url} alt={award.name} className="size-16 object-contain" />
) : null}
</div>
)}
<ItemTitle className={cn("mt-1", isMedal && "text-lg text-shadow-lg text-shadow-white/15")}>
{award.name}
</ItemTitle>
<span className="text-[9px] uppercase tracking-wider text-muted-foreground font-mono">
{type}
</span>
</ItemHeader>
<ItemContent className="flex flex-col gap-2">
<ItemDescription className="min-w-0 break-words text-center text-sm leading-relaxed">
{award.description
? convertLexicalToPlaintext({ data: award.description })
: "No description available."}
</ItemDescription>
{award.experiencePoints != null && (
<span className="mt-1 font-mono text-sm text-muted-foreground">
+{award.experiencePoints} XP
</span>
)}
</ItemContent>
{showGrantButton && (
<div className="mt-auto w-full">
{canGrant === false ? (
<Button
variant="outline"
size="sm"
disabled
className="w-full opacity-50 cursor-not-allowed"
>
<LockIcon data-icon="inline-start" />
You cannot grant this award
</Button>
) : onGrantClick ? (
<Button
variant="outline"
size="sm"
onClick={onGrantClick}
className="w-full flex flex-col gap-0 h-full py-1.5"
>
<span className="flex flex-row gap-1">
<AwardIcon data-icon="inline-start" />
Award this
</span>
<span className="opacity-50">
{remainingGrants === undefined
? ""
: remainingGrants === null
? ` (\u221E grant${remainingGrants === 1 ? "" : "s"} remaining)`
: ` (${remainingGrants} grant${remainingGrants === 1 ? "" : "s"} remaining)`}{" "}
</span>
</Button>
) : null}
</div>
)}
</Item>
);
}

View file

@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import type { Award } from "@/payload-types";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner";
import { RibbonViewer } from "./RibbonViewer";
import { GrantAwardDialog } from "./GrantAwardDialog";
import { MedalIcon, RibbonIcon, ScrollTextIcon } from "lucide-react";
interface AwardPickerDialogProps {
awards: Award[];
recipientUserId: number;
recipientUsername: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onGranted?: () => void;
}
/**
* Grid of grantable awards. Selecting a card closes this dialog and opens the
* GrantAwardDialog for that award with the recipient pre-filled.
*/
export function AwardPickerDialog({
awards,
recipientUserId,
recipientUsername,
open,
onOpenChange,
onGranted,
}: AwardPickerDialogProps) {
const [selectedAward, setSelectedAward] = useState<Award | null>(null);
function pick(award: Award) {
setSelectedAward(award);
onOpenChange(false);
}
return (
<>
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) setSelectedAward(null);
onOpenChange(o);
}}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Select an award</DialogTitle>
<DialogDescription>
Choose which award to grant to @{recipientUsername}.
</DialogDescription>
</DialogHeader>
{awards.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No awards are available to grant.
</p>
) : (
<div className="grid max-h-[50vh] grid-cols-1 gap-3 overflow-y-auto sm:grid-cols-2 md:grid-cols-3">
{awards.map((award) => (
<button
key={award.id}
type="button"
onClick={() => pick(award)}
className="flex flex-col items-center gap-2 rounded-md border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-accent"
>
{award.type === "ribbon" && award.ribbonDesign ? (
<div className="flex h-16 w-full items-center justify-center">
<div className="w-32 max-w-full">
<RibbonViewer ribbonDesign={award.ribbonDesign as RibbonDesign} showRibbonOnly />
</div>
</div>
) : (
<span className="flex size-16 items-center justify-center rounded-md bg-muted">
{award.type === "medal" ? (
<MedalIcon className="size-6 text-muted-foreground" />
) : award.type === "commendation" ? (
<ScrollTextIcon className="size-6 text-muted-foreground" />
) : (
<RibbonIcon className="size-6 text-muted-foreground" />
)}
</span>
)}
<span className="line-clamp-2 text-center text-sm font-medium">
{award.name}
</span>
<Badge variant="secondary" className="w-fit capitalize">
{award.type ?? "award"}
</Badge>
</button>
))}
</div>
)}
</DialogContent>
</Dialog>
{selectedAward && (
<GrantAwardDialog
awardId={selectedAward.id}
awardName={selectedAward.name}
recipientUserId={recipientUserId}
recipientUsername={recipientUsername}
open
onOpenChange={(o) => {
if (!o) setSelectedAward(null);
}}
onGranted={onGranted}
/>
)}
</>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import type { Award, Media } from "@/payload-types";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { AwardListingCard } from "./AwardListingCard";
import { GrantAwardDialog } from "./GrantAwardDialog";
import { getGrantStats } from "@/app/(frontend)/awards/grant-context";
interface AwardWithGrantButtonProps {
award: Award;
awardImage?: Media | null;
}
export function AwardWithGrantButton({ award, awardImage }: AwardWithGrantButtonProps) {
const [open, setOpen] = useState(false);
const [canGrant, setCanGrant] = useState<boolean | null>(null);
const [remainingGrants, setRemainingGrants] = useState<number | null | undefined>(undefined);
const router = useRouter();
useEffect(() => {
let cancelled = false;
getGrantStats(award.id).then((res) => {
if (cancelled) return;
if (res.success && res.data) {
setCanGrant(res.data.canGrant);
setRemainingGrants(res.data.remainingGrants);
} else {
setCanGrant(null);
}
});
return () => {
cancelled = true;
};
}, [award.id]);
return (
<>
<AwardListingCard
award={award}
awardImage={awardImage}
showGrantButton
onGrantClick={() => setOpen(true)}
canGrant={canGrant}
remainingGrants={remainingGrants}
/>
<GrantAwardDialog
awardId={award.id}
awardName={award.name}
awardDescription={convertLexicalToPlaintext({ data: award.description })}
showRecipientPicker
open={open}
onOpenChange={setOpen}
onGranted={() => router.refresh()}
/>
</>
);
}

View file

@ -0,0 +1,263 @@
"use client";
import { useEffect, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { grantAward } from "@/app/(frontend)/awards/actions";
import { getGrantStats, type GrantStats } from "@/app/(frontend)/awards/grant-context";
import { RecipientPicker, type RecipientSelection } from "./RecipientPicker";
import { Loader2Icon, TriangleAlertIcon } from "lucide-react";
import { toast } from "sonner";
interface GrantAwardDialogProps {
awardId: number;
awardName: string;
awardDescription?: string;
recipientUserId?: number;
recipientUsername?: string;
showRecipientPicker?: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
onGranted?: () => void;
}
/**
* Two-step grant flow: pick a recipient + reason, then confirm by typing the
* recipient's @username. Grant limits are pre-checked via getGrantStats so
* users see warnings before submitting.
*/
export function GrantAwardDialog({
awardId,
awardName,
awardDescription,
recipientUserId,
recipientUsername,
showRecipientPicker = false,
open,
onOpenChange,
onGranted,
}: GrantAwardDialogProps) {
const [step, setStep] = useState<1 | 2>(1);
const [recipient, setRecipient] = useState<RecipientSelection | null>(null);
const [reason, setReason] = useState("");
const [confirmText, setConfirmText] = useState("");
const [stats, setStats] = useState<GrantStats | null>(null);
const [statsError, setStatsError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset the flow each time the dialog opens; pre-fill a fixed recipient.
useEffect(() => {
if (!open) return;
setStep(1);
setReason("");
setConfirmText("");
setStats(null);
setStatsError(null);
setError(null);
setSubmitting(false);
setRecipient(
!showRecipientPicker && recipientUserId != null && recipientUsername
? { id: recipientUserId, username: recipientUsername }
: null,
);
}, [open, showRecipientPicker, recipientUserId, recipientUsername]);
// Load grant context for the award while the dialog is open.
useEffect(() => {
if (!open) return;
let cancelled = false;
getGrantStats(awardId).then((res) => {
if (cancelled) return;
if (res.success && res.data) setStats(res.data);
else setStatsError(res.error ?? "Failed to load grant stats.");
});
return () => {
cancelled = true;
};
}, [open, awardId]);
const expectedConfirm = recipient ? `@${recipient.username}` : null;
const confirmMatches = expectedConfirm !== null && confirmText.trim() === expectedConfirm;
const limited = stats?.remainingGrants != null;
const lowRemaining = limited && (stats?.remainingGrants ?? 0) <= 2;
async function handleGrant() {
if (!recipient || !confirmMatches || submitting) return;
setSubmitting(true);
setError(null);
const res = await grantAward({
recipientUserId: recipient.id,
awardId,
reason: reason.trim() ? reason.trim() : undefined,
});
if (res.success) {
toast.success("Award granted", {
description: `${awardName} granted to @${recipient.username}.`,
});
onGranted?.();
onOpenChange(false);
} else {
setError(res.error ?? "Failed to grant award.");
setSubmitting(false);
}
}
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!submitting) onOpenChange(o);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Grant {awardName}</DialogTitle>
<DialogDescription>
{showRecipientPicker
? "Select a recipient, then confirm the grant."
: `Granting to @${recipientUsername}.`}
{stats && stats.canGrant && (
<span className="block mt-1 text-xs opacity-50">
{stats.remainingGrants === null
? "Unlimited grants available."
: `${stats.remainingGrants} grant${stats.remainingGrants === 1 ? "" : "s"} remaining.`}
</span>
)}
</DialogDescription>
</DialogHeader>
{step === 1 ? (
<div className="flex flex-col gap-4">
{awardDescription && (
<p className="text-sm text-muted-foreground">{awardDescription}</p>
)}
{showRecipientPicker ? (
<div className="flex flex-col gap-2">
<Label>Recipient</Label>
<RecipientPicker
selectedUser={recipient}
onSelect={setRecipient}
onClear={() => setRecipient(null)}
/>
</div>
) : (
<p className="text-sm">
Recipient: <span className="font-medium">@{recipientUsername}</span>
</p>
)}
<div className="flex flex-col gap-2">
<Label>Reason (optional)</Label>
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
placeholder="Citation or reason for the award..."
/>
</div>
{statsError && (
<Alert variant="destructive">
<TriangleAlertIcon data-icon="inline-start" />
<AlertTitle>Could not verify grant limits</AlertTitle>
<AlertDescription>{statsError}</AlertDescription>
</Alert>
)}
{stats && !stats.canGrant && (
<Alert variant="destructive">
<TriangleAlertIcon data-icon="inline-start" />
<AlertTitle>You are not authorized to grant this award</AlertTitle>
<AlertDescription>
Your current role and assignments do not allow you to grant this award.
</AlertDescription>
</Alert>
)}
{stats && stats.canGrant && lowRemaining && (
<Alert>
<TriangleAlertIcon data-icon="inline-start" />
<AlertTitle>Limited award</AlertTitle>
<AlertDescription>
You have {stats.remainingGrants} grant
{(stats.remainingGrants ?? 0) === 1 ? "" : "s"} remaining for this award.
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => setStep(2)}
disabled={!recipient || (stats !== null && !stats.canGrant)}
>
Continue
</Button>
</DialogFooter>
</div>
) : (
<div className="flex flex-col gap-4">
<Alert>
<TriangleAlertIcon data-icon="inline-start" />
<AlertTitle>Confirm grant</AlertTitle>
<AlertDescription>
You are about to grant "{awardName}" to @{recipient?.username}. This action cannot
be undone.
</AlertDescription>
</Alert>
{limited && (
<p className="text-sm text-muted-foreground">
{(stats?.remainingGrants ?? 0) <= 1
? "This will use your LAST grant for this award."
: `Grants remaining after this: ${(stats?.remainingGrants ?? 0) - 1}.`}
</p>
)}
<div className="flex flex-col gap-2">
<Label>Type @{recipient?.username} to confirm</Label>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={`@${recipient?.username}`}
autoFocus
/>
</div>
{error && (
<Alert variant="destructive">
<TriangleAlertIcon data-icon="inline-start" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setStep(1)} disabled={submitting}>
Back
</Button>
<Button onClick={handleGrant} disabled={!confirmMatches || submitting}>
{submitting && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
Confirm grant
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,214 @@
import type { CSSProperties } from "react";
/**
* Frontend-safe SVG renderer for a medal's art. Mirrors the admin
* `MedalDesigner` preview so the award listing page can show medal art
* without pulling in Payload admin UI dependencies. Self-contained — the
* shape/finish constants intentionally duplicate the admin module so this
* file stays client- and server-safe.
*/
export type MedalShape = "circle" | "star" | "cross" | "shield";
export type MetalFinish = "gold" | "silver" | "bronze" | "copper" | "darkSteel";
export interface MedalDesign {
ribbonStripes: { color: string; width: number }[];
shape: MedalShape;
finish: MetalFinish;
engraving?: string;
}
const FINISH_INFO: Record<MetalFinish, { colors: string[] }> = {
gold: { colors: ["#fbbf24", "#ca8a04", "#92400e"] },
silver: { colors: ["#e5e7eb", "#9ca3af", "#6b7280"] },
bronze: { colors: ["#d97706", "#92400e", "#78350f"] },
copper: { colors: ["#f97316", "#c2410c", "#7c2d12"] },
darkSteel: { colors: ["#6b7280", "#4b5563", "#374151"] },
};
const RIBBON_WIDTH = 366;
function isMedalDesign(value: unknown): value is MedalDesign {
return (
!!value &&
typeof value === "object" &&
Array.isArray((value as MedalDesign).ribbonStripes) &&
((value as MedalDesign).ribbonStripes as unknown[]).length > 0
);
}
function MedalBody({
shape,
finish,
engraving,
cx,
cy,
}: {
shape: MedalShape;
finish: MetalFinish;
engraving: string;
cx: number;
cy: number;
}) {
const finishColors = FINISH_INFO[finish].colors;
const gradId = `medal-grad-${finish}`;
const r = 40;
return (
<g>
<defs>
<radialGradient id={gradId} cx="40%" cy="35%">
<stop offset="0%" stopColor={finishColors[0]} />
<stop offset="60%" stopColor={finishColors[1]} />
<stop offset="100%" stopColor={finishColors[2]} />
</radialGradient>
</defs>
{shape === "circle" && (
<circle
cx={cx}
cy={cy}
r={r}
fill={`url(#${gradId})`}
stroke={finishColors[2]}
strokeWidth={1.5}
/>
)}
{shape === "star" && (
<polygon
points={Array.from({ length: 10 }, (_, i) => {
const angle = Math.PI / 2 + (i * Math.PI) / 5;
const radius = i % 2 === 0 ? r : r * 0.45;
return `${cx + radius * Math.cos(angle)},${cy - radius * Math.sin(angle)}`;
}).join(" ")}
fill={`url(#${gradId})`}
stroke={finishColors[2]}
strokeWidth={1.5}
/>
)}
{shape === "cross" && (
<path
d={`M${cx - 12},${cy - r} L${cx + 12},${cy - r} L${cx + 12},${cy - 12}
L${cx + r},${cy - 12} L${cx + r},${cy + 12} L${cx + 12},${cy + 12}
L${cx + 12},${cy + r} L${cx - 12},${cy + r} L${cx - 12},${cy + 12}
L${cx - r},${cy + 12} L${cx - r},${cy - 12} L${cx - 12},${cy - 12}Z`}
fill={`url(#${gradId})`}
stroke={finishColors[2]}
strokeWidth={1.5}
/>
)}
{shape === "shield" && (
<path
d={`M${cx},${cy - r}
Q${cx + r},${cy - r * 0.6} ${cx + r * 0.9},${cy - r * 0.1}
L${cx},${cy + r}
L${cx - r * 0.9},${cy - r * 0.1}
Q${cx - r},${cy - r * 0.6} ${cx},${cy - r}Z`}
fill={`url(#${gradId})`}
stroke={finishColors[2]}
strokeWidth={1.5}
/>
)}
{engraving && (
<text
x={cx}
y={cy + 2}
textAnchor="middle"
dominantBaseline="central"
fill={finishColors[2]}
fontSize={engraving.length > 10 ? 6 : 8}
fontFamily="serif"
fontWeight="bold"
letterSpacing={0.5}
>
{engraving.toUpperCase()}
</text>
)}
</g>
);
}
export function MedalViewer({
medalDesign,
className,
style,
}: {
medalDesign: MedalDesign | unknown;
className?: string;
style?: CSSProperties;
}) {
const design = isMedalDesign(medalDesign) ? medalDesign : null;
if (!design) return null;
const { ribbonStripes, shape, finish, engraving = "" } = design;
const totalWeight = ribbonStripes.reduce((s, stripe) => s + stripe.width, 0);
const viewWidth = RIBBON_WIDTH;
const ribbonTop = 14;
const ribbonBottom = 72;
const convergeRatio = 0.35;
const medalCx = viewWidth / 2;
const medalCy = 120;
const ribbonViewWidth = RIBBON_WIDTH;
const offset = 0;
return (
<svg
viewBox={`0 0 ${viewWidth} 175`}
className={className ?? "w-full h-auto rounded border border-zinc-700 bg-zinc-950"}
style={{ maxHeight: 220, ...style }}
>
{(() => {
const topStartX = offset;
const bottomCenter = medalCx;
const topWidth = ribbonViewWidth;
const bottomWidth = topWidth * convergeRatio;
let topX = topStartX;
return ribbonStripes.map((stripe, i) => {
const stripeW = (stripe.width / totalWeight) * ribbonViewWidth;
const fracStart = (topX - topStartX) / topWidth;
const fracEnd = (topX + stripeW - topStartX) / topWidth;
const tlx = topX;
const trx = topX + stripeW;
const blx = bottomCenter - bottomWidth / 2 + fracStart * bottomWidth;
const brx = bottomCenter - bottomWidth / 2 + fracEnd * bottomWidth;
topX += stripeW;
return (
<polygon
key={i}
points={`${tlx},${ribbonTop} ${trx},${ribbonTop} ${brx},${ribbonBottom} ${blx},${ribbonBottom}`}
fill={stripe.color}
/>
);
});
})()}
{/* Small connector tab between ribbon and medal */}
<rect
x={medalCx - 4}
y={ribbonBottom}
width={8}
height={8}
rx={2}
fill="#9ca3af"
/>
{/* Medal body */}
<MedalBody
shape={shape}
finish={finish}
engraving={engraving}
cx={medalCx}
cy={medalCy}
/>
</svg>
);
}

View file

@ -0,0 +1,49 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { Award } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { AwardPickerDialog } from "./AwardPickerDialog";
import { AwardIcon, PlusIcon } from "lucide-react";
interface ProfileGrantAwardButtonProps {
userId: number;
username: string;
awards: Award[];
}
/**
* Profile-page section that lets viewers grant an award to the profile owner.
* Eligibility is enforced server-side (getGrantStats / grantAward inside
* GrantAwardDialog) — this component only provides the entry point.
*/
export function ProfileGrantAwardButton({ userId, username, awards }: ProfileGrantAwardButtonProps) {
const [open, setOpen] = useState(false);
const router = useRouter();
return (
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<AwardIcon className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Grant Award
</h2>
</div>
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
<PlusIcon data-icon="inline-start" />
Grant Award
</Button>
</div>
<AwardPickerDialog
awards={awards}
recipientUserId={userId}
recipientUsername={username}
open={open}
onOpenChange={setOpen}
onGranted={() => router.refresh()}
/>
</section>
);
}

View file

@ -0,0 +1,254 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { Assignment, Media, Profile } from "@/payload-types";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { Loader2Icon, SearchIcon, XIcon } from "lucide-react";
export interface RecipientSelection {
id: number;
username: string;
displayName?: string | null;
profileId?: number;
}
interface RecipientPickerProps {
onSelect: (user: RecipientSelection) => void;
selectedUser: RecipientSelection | null;
onClear: () => void;
}
interface SearchResult extends RecipientSelection {
rankName?: string | null;
insigniaUrl?: string | null;
assignmentLabel?: string | null;
}
function initialsOf(username: string): string {
return username.slice(0, 2).toUpperCase();
}
/**
* Searchable recipient picker. Queries the profiles REST API (users have no
* `profile` relationship, so searching /api/users would not surface rank
* data) and enriches each hit with the user's primary assignment.
*/
export function RecipientPicker({ onSelect, selectedUser, onClear }: RecipientPickerProps) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const wrapperRef = useRef<HTMLDivElement>(null);
const searchSeq = useRef(0);
// Debounced search with a sequence guard so stale responses are dropped.
useEffect(() => {
const trimmed = query.trim();
if (trimmed.length < 1) {
setResults([]);
setSearching(false);
return;
}
const seq = ++searchSeq.current;
setSearching(true);
const timer = setTimeout(async () => {
try {
const q = encodeURIComponent(trimmed);
const url = `/api/profiles?where[or][0][user.username][like]=${q}&where[or][1][user.displayName][like]=${q}&limit=10&depth=2`;
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error("Search failed");
const data = (await res.json()) as { docs?: unknown[] };
const mapped: SearchResult[] = [];
for (const doc of data.docs ?? []) {
const profile = doc as Profile;
const userDoc = profile.user;
if (typeof userDoc !== "object" || !userDoc) continue;
const rank = typeof profile.rank === "object" && profile.rank ? profile.rank : null;
mapped.push({
id: userDoc.id,
username: userDoc.username,
displayName: userDoc.displayName,
profileId: profile.id,
rankName: rank?.name ?? null,
insigniaUrl:
rank && typeof rank.insignia === "object" && rank.insignia
? (rank.insignia as Media).url
: null,
});
}
// Enrich with each user's primary assignment (best-effort).
const assignmentResults = await Promise.allSettled(
mapped.map((r) =>
fetch(`/api/assignments?where[members][equals]=${r.id}&limit=1&depth=0`, {
cache: "no-store",
})
.then((res) => (res.ok ? res.json() : null))
.then((json) => {
const doc = (json as { docs?: unknown[] } | null)?.docs?.[0];
if (!doc || typeof doc !== "object") return null;
const assignment = doc as Assignment;
return assignment.callsign ? `[${assignment.callsign}] ${assignment.name}` : assignment.name;
}),
),
);
assignmentResults.forEach((res, i) => {
if (res.status === "fulfilled" && res.value) mapped[i].assignmentLabel = res.value;
});
if (seq !== searchSeq.current) return;
setResults(mapped);
setActiveIndex(0);
} catch {
if (seq === searchSeq.current) {
setResults([]);
}
} finally {
if (seq === searchSeq.current) setSearching(false);
}
}, 300);
return () => clearTimeout(timer);
}, [query]);
// Close the dropdown on outside click.
useEffect(() => {
if (!open) return;
function handlePointerDown(e: MouseEvent) {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, [open]);
function select(result: SearchResult) {
onSelect({
id: result.id,
username: result.username,
displayName: result.displayName,
profileId: result.profileId,
});
setQuery("");
setOpen(false);
}
if (selectedUser) {
return (
<div className="flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-2">
<Avatar className="size-7">
<AvatarFallback>{initialsOf(selectedUser.username)}</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">
{selectedUser.displayName || selectedUser.username}
</span>
<span className="text-sm text-muted-foreground">@{selectedUser.username}</span>
<button
type="button"
aria-label="Clear recipient"
onClick={onClear}
className="ml-auto rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<XIcon className="size-4" />
</button>
</div>
);
}
return (
<div ref={wrapperRef} className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
return;
}
if (!open || results.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const active = results[activeIndex];
if (active) select(active);
}
}}
placeholder="Search by username or display name..."
className="pl-8"
role="combobox"
aria-expanded={open}
aria-controls="recipient-picker-results"
/>
{open && query.trim().length > 0 && (
<div
id="recipient-picker-results"
role="listbox"
className="absolute z-50 mt-1 max-h-80 w-full overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md"
>
{searching ? (
<div className="flex items-center gap-2 px-3 py-3 text-sm text-muted-foreground">
<Loader2Icon className="size-4 animate-spin" />
Searching...
</div>
) : results.length === 0 ? (
<div className="px-3 py-3 text-sm text-muted-foreground">No users found.</div>
) : (
results.map((result, i) => (
<button
key={result.id}
type="button"
role="option"
aria-selected={i === activeIndex}
onMouseDown={(e) => e.preventDefault()}
onClick={() => select(result)}
onMouseEnter={() => setActiveIndex(i)}
className={cn(
"flex w-full items-center gap-3 px-3 py-2 text-left",
i === activeIndex && "bg-accent",
)}
>
<Avatar className="size-8">
{result.insigniaUrl ? (
<AvatarImage src={result.insigniaUrl} alt="" />
) : (
<AvatarFallback>{initialsOf(result.username)}</AvatarFallback>
)}
</Avatar>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">
{result.displayName || result.username}
</span>
<span className="truncate text-xs text-muted-foreground">
@{result.username}
{result.rankName ? ` · ${result.rankName}` : ""}
</span>
</span>
{result.assignmentLabel && (
<span className="ml-auto shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{result.assignmentLabel}
</span>
)}
</button>
))
)}
</div>
)}
</div>
);
}

View file

@ -6,7 +6,7 @@ import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintex
export const RibbonRack = ({ awards }: { awards: Award[] }) => { export const RibbonRack = ({ awards }: { awards: Award[] }) => {
return ( return (
<div className="flex flex-row w-52 gap-1"> <div className="flex flex-wrap w-[19.625rem] max-w-full gap-x-1 gap-y-px">
{awards {awards
.filter((award) => award.type === "ribbon" && award.ribbonDesign != null) .filter((award) => award.type === "ribbon" && award.ribbonDesign != null)
.sort((a, b) => (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0)) .sort((a, b) => (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0))
@ -14,7 +14,7 @@ export const RibbonRack = ({ awards }: { awards: Award[] }) => {
const ribbon = award.ribbonDesign as RibbonDesign; const ribbon = award.ribbonDesign as RibbonDesign;
return ( return (
<Tooltip key={key}> <Tooltip key={key}>
<TooltipTrigger className="w-full max-h-6.75 hover:scale-110 shadow-black shadow-xs hover:shadow-xl transition-transform"> <TooltipTrigger className="w-[6.375rem] max-w-full h-[1.6875rem] hover:scale-110 shadow-black shadow-xs hover:shadow-xl transition-transform">
<RibbonViewer ribbonDesign={ribbon} showRibbonOnly /> <RibbonViewer ribbonDesign={ribbon} showRibbonOnly />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>

View file

@ -2,6 +2,7 @@
import * as React from "react"; import * as React from "react";
import { import {
Award,
Backpack, Backpack,
Bolt, Bolt,
Crosshair, Crosshair,
@ -134,6 +135,11 @@ const data = {
}, },
], ],
navPersonal: [ navPersonal: [
{
title: "Awards",
url: "/awards",
icon: Award,
},
{ {
title: "Locker", title: "Locker",
url: "/locker", url: "/locker",

178
src/lib/awards/grant.ts Normal file
View file

@ -0,0 +1,178 @@
import type { Payload } from "payload";
import type { Assignment, Profile } from "@/payload-types";
type AssignmentType = Assignment["type"];
type RecipientScope = "same_command" | "whitelist" | "anyone";
const MAX_TREE_DEPTH = 5;
/** Extract the numeric id from a relationship reference (raw id or populated doc). */
function refId(ref: number | { id?: unknown } | null | undefined): number | null {
if (typeof ref === "number") return ref;
if (ref && typeof ref.id === "number") return ref.id;
return null;
}
/**
* Assignments where the given user is the leader and whose type is in
* `allowedTypes`. An empty `allowedTypes` means no leader may grant.
*/
export async function getGrantorAssignments(
payload: Payload,
userId: number,
allowedTypes: readonly AssignmentType[],
): Promise<Assignment[]> {
if (allowedTypes.length === 0) return [];
const res = await payload.find({
collection: "assignments",
where: {
and: [
{ leader: { equals: userId } },
{ type: { in: [...allowedTypes] } },
],
},
limit: 100,
depth: 0,
overrideAccess: true,
});
return res.docs;
}
async function collectMembers(
payload: Payload,
assignmentId: number,
depth: number,
collected: Set<number>,
): Promise<void> {
if (depth > MAX_TREE_DEPTH) return;
let assignment: Assignment | null = null;
try {
assignment = await payload.findByID({
collection: "assignments",
id: assignmentId,
depth: 0,
overrideAccess: true,
});
} catch {
return;
}
if (!assignment) return;
const leaderId = refId(assignment.leader);
if (leaderId !== null) collected.add(leaderId);
for (const member of assignment.members ?? []) {
const id = refId(member);
if (id !== null) collected.add(id);
}
for (const sub of assignment.subAssignments ?? []) {
const id = refId(sub);
if (id !== null) await collectMembers(payload, id, depth + 1, collected);
}
}
/**
* All member user ids under an assignment: its leader, its direct members,
* and (recursively) the members of its sub-assignments. Depth-capped to
* prevent infinite loops on circular sub-assignment graphs.
*/
export async function getAssignmentTreeMembers(
payload: Payload,
assignmentId: number,
depth = 0,
): Promise<number[]> {
const collected = new Set<number>();
await collectMembers(payload, assignmentId, depth, collected);
return [...collected];
}
/**
* How many times `grantorProfileId` has already issued `awardId` across all
* profiles. Scans every profile (depth 0) and counts matching grant rows.
*/
export async function countGrantsByGrantor(
payload: Payload,
awardId: number,
grantorProfileId: number,
): Promise<number> {
const res = await payload.find({
collection: "profiles",
limit: 1000,
depth: 0,
overrideAccess: true,
});
let count = 0;
for (const profile of res.docs) {
for (const entry of profile.progression?.awards ?? []) {
if (refId(entry.award) === awardId && refId(entry.awardedBy) === grantorProfileId) {
count += 1;
}
}
}
return count;
}
async function isDirectMember(
payload: Payload,
assignmentId: number,
userId: number,
): Promise<boolean> {
let assignment: Assignment | null = null;
try {
assignment = await payload.findByID({
collection: "assignments",
id: assignmentId,
depth: 0,
overrideAccess: true,
});
} catch {
return false;
}
if (!assignment) return false;
if (refId(assignment.leader) === userId) return true;
return (assignment.members ?? []).some((m) => refId(m) === userId);
}
/**
* Whether `recipientUserId` may receive an award under the given scope.
* - anyone: always eligible
* - whitelist: a direct member of any assignment in `whitelistIds`
* - same_command: a member of the grantor's assignment tree
*/
export async function isRecipientEligible(
payload: Payload,
recipientUserId: number,
scope: RecipientScope | null | undefined,
grantorAssignmentId?: number,
whitelistIds?: (number | Assignment)[] | null,
): Promise<boolean> {
if (!scope || scope === "anyone") return true;
if (scope === "whitelist") {
const ids = (whitelistIds ?? [])
.map((ref) => refId(ref))
.filter((id): id is number => id !== null);
for (const assignmentId of ids) {
if (await isDirectMember(payload, assignmentId, recipientUserId)) return true;
}
return false;
}
if (!grantorAssignmentId) return false;
const members = await getAssignmentTreeMembers(payload, grantorAssignmentId);
return members.includes(recipientUserId);
}
/** The profile belonging to `userId`, or null if none exists. */
export async function findUserProfile(
payload: Payload,
userId: number,
): Promise<Profile | null> {
const res = await payload.find({
collection: "profiles",
where: { user: { equals: userId } },
limit: 1,
depth: 0,
overrideAccess: true,
});
return res.docs[0] ?? null;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,43 @@
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_awards_grant_config_allowed_grantor_assignment_types" AS ENUM('division', 'squad', 'team', 'special');
CREATE TYPE "public"."enum_awards_grant_config_recipient_scope" AS ENUM('same_command', 'whitelist', 'anyone');
CREATE TABLE "awards_grant_config_allowed_grantor_assignment_types" (
"order" integer NOT NULL,
"parent_id" integer NOT NULL,
"value" "enum_awards_grant_config_allowed_grantor_assignment_types",
"id" serial PRIMARY KEY NOT NULL
);
CREATE TABLE "awards_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"assignments_id" integer
);
ALTER TABLE "awards" ADD COLUMN "grant_config_max_grants_per_grantor" numeric;
ALTER TABLE "awards" ADD COLUMN "grant_config_recipient_scope" "enum_awards_grant_config_recipient_scope" DEFAULT 'same_command';
ALTER TABLE "awards_grant_config_allowed_grantor_assignment_types" ADD CONSTRAINT "awards_grant_config_allowed_grantor_assignment_types_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."awards"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "awards_rels" ADD CONSTRAINT "awards_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."awards"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "awards_rels" ADD CONSTRAINT "awards_rels_assignments_fk" FOREIGN KEY ("assignments_id") REFERENCES "public"."assignments"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "awards_grant_config_allowed_grantor_assignment_types_order_idx" ON "awards_grant_config_allowed_grantor_assignment_types" USING btree ("order");
CREATE INDEX "awards_grant_config_allowed_grantor_assignment_types_parent_idx" ON "awards_grant_config_allowed_grantor_assignment_types" USING btree ("parent_id");
CREATE INDEX "awards_rels_order_idx" ON "awards_rels" USING btree ("order");
CREATE INDEX "awards_rels_parent_idx" ON "awards_rels" USING btree ("parent_id");
CREATE INDEX "awards_rels_path_idx" ON "awards_rels" USING btree ("path");
CREATE INDEX "awards_rels_assignments_id_idx" ON "awards_rels" USING btree ("assignments_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP TABLE "awards_grant_config_allowed_grantor_assignment_types" CASCADE;
DROP TABLE "awards_rels" CASCADE;
ALTER TABLE "awards" DROP COLUMN "grant_config_max_grants_per_grantor";
ALTER TABLE "awards" DROP COLUMN "grant_config_recipient_scope";
DROP TYPE "public"."enum_awards_grant_config_allowed_grantor_assignment_types";
DROP TYPE "public"."enum_awards_grant_config_recipient_scope";`)
}

View file

@ -1,45 +1,46 @@
import * as migration_20260820_004157 from "./20260820_004157"; import * as migration_20260820_004157 from './20260820_004157';
import * as migration_20260825_220000 from "./20260825_220000"; import * as migration_20260825_220000 from './20260825_220000';
import * as migration_20260826_050000_repair_shims_auxiliary_tables from "./20260826_050000_repair_shims_auxiliary_tables"; import * as migration_20260826_050000_repair_shims_auxiliary_tables from './20260826_050000_repair_shims_auxiliary_tables';
import * as migration_20260826_060000_add_game_servers_event_target from "./20260826_060000_add_game_servers_event_target"; import * as migration_20260826_060000_add_game_servers_event_target from './20260826_060000_add_game_servers_event_target';
import * as migration_20260827_005757 from "./20260827_005757"; import * as migration_20260827_005757 from './20260827_005757';
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from "./20260827_022334_add_eod_minesweeper_best_flags"; import * as migration_20260827_022334_add_eod_minesweeper_best_flags from './20260827_022334_add_eod_minesweeper_best_flags';
import * as migration_20260828_011500_add_background_texture_preference from "./20260828_011500_add_background_texture_preference";
import * as migration_20260827_213857_add_evaluations_collection from './20260827_213857_add_evaluations_collection'; import * as migration_20260827_213857_add_evaluations_collection from './20260827_213857_add_evaluations_collection';
import * as migration_20260827_233224_add_mission_completed_at from './20260827_233224_add_mission_completed_at'; import * as migration_20260827_233224_add_mission_completed_at from './20260827_233224_add_mission_completed_at';
import * as migration_20260828_011500_add_background_texture_preference from './20260828_011500_add_background_texture_preference';
import * as migration_20260828_041615_add_campaign_mode from './20260828_041615_add_campaign_mode'; import * as migration_20260828_041615_add_campaign_mode from './20260828_041615_add_campaign_mode';
import * as migration_20260828_044506 from './20260828_044506'; import * as migration_20260828_044506 from './20260828_044506';
import * as migration_20260831_142503 from './20260831_142503';
export const migrations = [ export const migrations = [
{ {
up: migration_20260820_004157.up, up: migration_20260820_004157.up,
down: migration_20260820_004157.down, down: migration_20260820_004157.down,
name: "20260820_004157", name: '20260820_004157',
}, },
{ {
up: migration_20260825_220000.up, up: migration_20260825_220000.up,
down: migration_20260825_220000.down, down: migration_20260825_220000.down,
name: "20260825_220000", name: '20260825_220000',
}, },
{ {
up: migration_20260826_050000_repair_shims_auxiliary_tables.up, up: migration_20260826_050000_repair_shims_auxiliary_tables.up,
down: migration_20260826_050000_repair_shims_auxiliary_tables.down, down: migration_20260826_050000_repair_shims_auxiliary_tables.down,
name: "20260826_050000_repair_shims_auxiliary_tables", name: '20260826_050000_repair_shims_auxiliary_tables',
}, },
{ {
up: migration_20260826_060000_add_game_servers_event_target.up, up: migration_20260826_060000_add_game_servers_event_target.up,
down: migration_20260826_060000_add_game_servers_event_target.down, down: migration_20260826_060000_add_game_servers_event_target.down,
name: "20260826_060000_add_game_servers_event_target", name: '20260826_060000_add_game_servers_event_target',
}, },
{ {
up: migration_20260827_005757.up, up: migration_20260827_005757.up,
down: migration_20260827_005757.down, down: migration_20260827_005757.down,
name: "20260827_005757", name: '20260827_005757',
}, },
{ {
up: migration_20260827_022334_add_eod_minesweeper_best_flags.up, up: migration_20260827_022334_add_eod_minesweeper_best_flags.up,
down: migration_20260827_022334_add_eod_minesweeper_best_flags.down, down: migration_20260827_022334_add_eod_minesweeper_best_flags.down,
name: "20260827_022334_add_eod_minesweeper_best_flags", name: '20260827_022334_add_eod_minesweeper_best_flags',
}, },
{ {
up: migration_20260827_213857_add_evaluations_collection.up, up: migration_20260827_213857_add_evaluations_collection.up,
@ -54,7 +55,7 @@ export const migrations = [
{ {
up: migration_20260828_011500_add_background_texture_preference.up, up: migration_20260828_011500_add_background_texture_preference.up,
down: migration_20260828_011500_add_background_texture_preference.down, down: migration_20260828_011500_add_background_texture_preference.down,
name: "20260828_011500_add_background_texture_preference", name: '20260828_011500_add_background_texture_preference',
}, },
{ {
up: migration_20260828_041615_add_campaign_mode.up, up: migration_20260828_041615_add_campaign_mode.up,
@ -64,6 +65,11 @@ export const migrations = [
{ {
up: migration_20260828_044506.up, up: migration_20260828_044506.up,
down: migration_20260828_044506.down, down: migration_20260828_044506.down,
name: '20260828_044506' name: '20260828_044506',
},
{
up: migration_20260831_142503.up,
down: migration_20260831_142503.down,
name: '20260831_142503'
}, },
]; ];

View file

@ -1753,16 +1753,24 @@ export interface Award {
| number | number
| boolean | boolean
| null; | null;
updatedAt: string;
createdAt: string;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * Configure who can grant this award and to whom. Only applies to ribbons and commendations.
* via the `definition` "qualifications".
*/ */
export interface Qualification { grantConfig?: {
id: number; /**
name: string; * Leaders of these assignment types can grant this award. Leave empty for admin/developer only.
*/
allowedGrantorAssignmentTypes?: ('division' | 'squad' | 'team' | 'special')[] | null;
/**
* Maximum times each grantor can issue this award. Empty = unlimited.
*/
maxGrantsPerGrantor?: number | null;
recipientScope?: ('same_command' | 'whitelist' | 'anyone') | null;
/**
* Only members of these assignments can receive this award.
*/
eligibleRecipientAssignments?: (number | Assignment)[] | null;
};
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
@ -1810,6 +1818,16 @@ export interface Assignment {
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "qualifications".
*/
export interface Qualification {
id: number;
name: string;
updatedAt: string;
createdAt: string;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "experience". * via the `definition` "experience".
@ -3547,6 +3565,14 @@ export interface AwardsSelect<T extends boolean = true> {
experiencePoints?: T; experiencePoints?: T;
ribbonDesign?: T; ribbonDesign?: T;
medalDesign?: T; medalDesign?: T;
grantConfig?:
| T
| {
allowedGrantorAssignmentTypes?: T;
maxGrantsPerGrantor?: T;
recipientScope?: T;
eligibleRecipientAssignments?: T;
};
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }

View file

@ -92,6 +92,9 @@ export const EventTypes = {
// Evaluations // Evaluations
EvaluationSubmit: "evaluation:submit", EvaluationSubmit: "evaluation:submit",
// Awards
AwardGrant: "award:grant",
// Missions // Missions
MissionAutoComplete: "mission:auto-complete", MissionAutoComplete: "mission:auto-complete",
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle", MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",