diff --git a/.cursorrules b/.cursorrules index 41c5415..52ec9ea 100644 --- a/.cursorrules +++ b/.cursorrules @@ -199,7 +199,7 @@ label="Note for Moderators" name="notes" value={params.notes} - rows={10} + inputProps={{ rows: 10 }} error={inputErrors.notes} /> @@ -207,7 +207,7 @@ - + ``` diff --git a/.gitignore b/.gitignore index 54e225b..cb31a8c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ dump*.sql *.dump *.log *.bak -migrate.py \ No newline at end of file +migrate.py +sync-all.sh \ No newline at end of file diff --git a/justfile b/justfile index 33a74f8..a94a3ad 100644 --- a/justfile +++ b/justfile @@ -15,14 +15,24 @@ import-triggers: docker compose exec -T database psql -U ${DATABASE_USER:-kycnot} -d ${DATABASE_NAME:-kycnot} < "$sql_file" done +# Create a database backup that includes the Prisma migrations table (recommended) dump-db: #!/bin/bash mkdir -p backups TIMESTAMP=$(date +%Y%m%d_%H%M%S) - echo "Creating database backup (excluding _prisma_migrations table)..." - docker compose exec -T database pg_dump -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} -c -F c -T _prisma_migrations > backups/db_backup_${TIMESTAMP}.dump + echo "Creating complete database backup (including _prisma_migrations table)..." + docker compose exec -T database pg_dump -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} -c -F c > backups/db_backup_${TIMESTAMP}.dump echo "Backup saved to backups/db_backup_${TIMESTAMP}.dump" +# Create a database backup without the migrations table (legacy format) +dump-db-no-migrations: + #!/bin/bash + mkdir -p backups + TIMESTAMP=$(date +%Y%m%d_%H%M%S) + echo "Creating database backup (excluding _prisma_migrations table)..." + docker compose exec -T database pg_dump -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} -c -F c -T _prisma_migrations > backups/db_backup_no_migrations_${TIMESTAMP}.dump + echo "Backup saved to backups/db_backup_no_migrations_${TIMESTAMP}.dump" + # Import a database backup. Usage: just import-db [filename] # If no filename is provided, it will use the most recent backup import-db file="": @@ -44,7 +54,96 @@ import-db file="": echo "Restoring database from $BACKUP_FILE..." # First drop all connections to the database docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DATABASE:-kycnot}' AND pid <> pg_backend_pid();" postgres - # Then restore the database - cat "$BACKUP_FILE" | docker compose exec -T database pg_restore -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} --clean --if-exists + + # Drop and recreate database + echo "Dropping and recreating the database..." + docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "DROP DATABASE IF EXISTS ${POSTGRES_DATABASE:-kycnot};" postgres + docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "CREATE DATABASE ${POSTGRES_DATABASE:-kycnot};" postgres + + # Restore the database + cat "$BACKUP_FILE" | docker compose exec -T database pg_restore -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} --no-owner echo "Database restored successfully!" + + # Import triggers + echo "Importing triggers..." + just import-triggers + + echo "Database import completed!" + # Check if migrations need to be run + cd web && npx prisma migrate status + + #!/bin/bash + if [ -z "{{file}}" ]; then + BACKUP_FILE=$(find backups/ -name 'db_backup_*.dump' | sort -r | head -n 1) + if [ -z "$BACKUP_FILE" ]; then + echo "Error: No backup files found in the backups directory" + exit 1 + fi + else + BACKUP_FILE="{{file}}" + if [ ! -f "$BACKUP_FILE" ]; then + echo "Error: Backup file '$BACKUP_FILE' not found" + exit 1 + fi + fi + + echo "=== STEP 1: PREPARING DATABASE ===" + # Drop all connections to the database + docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DATABASE:-kycnot}' AND pid <> pg_backend_pid();" postgres + + # Drop and recreate database + echo "Dropping and recreating the database..." + docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "DROP DATABASE IF EXISTS ${POSTGRES_DATABASE:-kycnot};" postgres + docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "CREATE DATABASE ${POSTGRES_DATABASE:-kycnot};" postgres + + echo "=== STEP 2: RESTORING PRODUCTION DATA ===" + # Restore the database + cat "$BACKUP_FILE" | docker compose exec -T database pg_restore -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} --no-owner + echo "Database data restored successfully!" + + echo "=== STEP 3: CREATING PRISMA MIGRATIONS TABLE ===" + # Create the _prisma_migrations table if it doesn't exist + docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} -c " + CREATE TABLE IF NOT EXISTS _prisma_migrations ( + id VARCHAR(36) PRIMARY KEY NOT NULL, + checksum VARCHAR(64) NOT NULL, + finished_at TIMESTAMP WITH TIME ZONE, + migration_name VARCHAR(255) NOT NULL, + logs TEXT, + rolled_back_at TIMESTAMP WITH TIME ZONE, + started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + applied_steps_count INTEGER NOT NULL DEFAULT 0 + );" + + echo "=== STEP 4: REGISTERING MIGRATIONS ===" + # Only register migrations if the table is empty + migration_count=$(docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} -t -c "SELECT COUNT(*) FROM _prisma_migrations;") + if [ "$migration_count" -eq "0" ]; then + # For each migration, insert a record into _prisma_migrations + for migration_dir in $(find web/prisma/migrations -maxdepth 1 -mindepth 1 -type d | sort); do + migration_name=$(basename "$migration_dir") + sql_file="$migration_dir/migration.sql" + + if [ -f "$sql_file" ]; then + echo "Registering migration: $migration_name" + checksum=$(sha256sum "$sql_file" | cut -d' ' -f1) + uuid=$(uuidgen) + now=$(date -u +"%Y-%m-%d %H:%M:%S") + + docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} -c " + INSERT INTO _prisma_migrations (id, checksum, migration_name, logs, started_at, finished_at, applied_steps_count) + VALUES ('$uuid', '$checksum', '$migration_name', 'Registered during import', '$now', '$now', 1) + ON CONFLICT (migration_name) DO NOTHING;" + fi + done + else + echo "Migrations table already has entries. Skipping registration." + fi + + echo "=== STEP 5: IMPORTING TRIGGERS ===" + just import-triggers + + echo "Production database import completed successfully!" + echo "Migration status:" + cd web && npx prisma migrate status \ No newline at end of file diff --git a/web/prisma/migrations/20250519115947_karma_transaction_enums/migration.sql b/web/prisma/migrations/20250519115947_karma_transaction_enums/migration.sql new file mode 100644 index 0000000..b6755f7 --- /dev/null +++ b/web/prisma/migrations/20250519115947_karma_transaction_enums/migration.sql @@ -0,0 +1,19 @@ +/* + Warnings: + + - Changed the type of `action` on the `KarmaTransaction` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + +*/ +-- CreateEnum +CREATE TYPE "KarmaTransactionAction" AS ENUM ('COMMENT_APPROVED', 'COMMENT_VERIFIED', 'COMMENT_SPAM', 'COMMENT_SPAM_REVERTED', 'COMMENT_UPVOTE', 'COMMENT_DOWNVOTE', 'COMMENT_VOTE_REMOVED', 'SUGGESTION_APPROVED', 'MANUAL_ADJUSTMENT'); + +-- AlterTable +ALTER TABLE "KarmaTransaction" ADD COLUMN "grantedByUserId" INTEGER, +DROP COLUMN "action", +ADD COLUMN "action" "KarmaTransactionAction" NOT NULL; + +-- CreateIndex +CREATE INDEX "KarmaTransaction_grantedByUserId_idx" ON "KarmaTransaction"("grantedByUserId"); + +-- AddForeignKey +ALTER TABLE "KarmaTransaction" ADD CONSTRAINT "KarmaTransaction_grantedByUserId_fkey" FOREIGN KEY ("grantedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/web/prisma/migrations/20250519135045_announcements/migration.sql b/web/prisma/migrations/20250519135045_announcements/migration.sql new file mode 100644 index 0000000..a8d4975 --- /dev/null +++ b/web/prisma/migrations/20250519135045_announcements/migration.sql @@ -0,0 +1,20 @@ +-- CreateEnum +CREATE TYPE "AnnouncementType" AS ENUM ('INFO', 'WARNING', 'ALERT'); + +-- CreateTable +CREATE TABLE "Announcement" ( + "id" SERIAL NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "type" "AnnouncementType" NOT NULL, + "startDate" TIMESTAMP(3) NOT NULL, + "endDate" TIMESTAMP(3), + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Announcement_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Announcement_isActive_startDate_endDate_idx" ON "Announcement"("isActive", "startDate", "endDate"); diff --git a/web/prisma/migrations/20250519145000_fix_karma_transactions/migration.sql b/web/prisma/migrations/20250519145000_fix_karma_transactions/migration.sql new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/web/prisma/migrations/20250519145000_fix_karma_transactions/migration.sql @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index 946a589..41a5fcf 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -166,6 +166,24 @@ enum ServiceSuggestionStatusChange { STATUS_CHANGED_TO_WITHDRAWN } +enum KarmaTransactionAction { + COMMENT_APPROVED + COMMENT_VERIFIED + COMMENT_SPAM + COMMENT_SPAM_REVERTED + COMMENT_UPVOTE + COMMENT_DOWNVOTE + COMMENT_VOTE_REMOVED + SUGGESTION_APPROVED + MANUAL_ADJUSTMENT +} + +enum AnnouncementType { + INFO + WARNING + ALERT +} + model Notification { id Int @id @default(autoincrement()) userId Int @@ -445,20 +463,21 @@ model User { /// Computed via trigger. Do not update through prisma. totalKarma Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - lastLoginAt DateTime @default(now()) - comments Comment[] - karmaTransactions KarmaTransaction[] - commentVotes CommentVote[] - suggestions ServiceSuggestion[] - suggestionMessages ServiceSuggestionMessage[] - internalNotes InternalUserNote[] @relation("UserRecievedNotes") - addedInternalNotes InternalUserNote[] @relation("UserAddedNotes") - verificationRequests ServiceVerificationRequest[] - notifications Notification[] @relation("NotificationOwner") - notificationPreferences NotificationPreferences? - serviceAffiliations ServiceUser[] @relation("UserServices") + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + lastLoginAt DateTime @default(now()) + comments Comment[] + karmaTransactions KarmaTransaction[] + grantedKarmaTransactions KarmaTransaction[] @relation("KarmaGrantedBy") + commentVotes CommentVote[] + suggestions ServiceSuggestion[] + suggestionMessages ServiceSuggestionMessage[] + internalNotes InternalUserNote[] @relation("UserRecievedNotes") + addedInternalNotes InternalUserNote[] @relation("UserAddedNotes") + verificationRequests ServiceVerificationRequest[] + notifications Notification[] @relation("NotificationOwner") + notificationPreferences NotificationPreferences? + serviceAffiliations ServiceUser[] @relation("UserServices") @@index([createdAt]) @@index([totalKarma]) @@ -489,24 +508,27 @@ model ServiceAttribute { } model KarmaTransaction { - id Int @id @default(autoincrement()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId Int - action String - points Int @default(0) - comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade) - commentId Int? - suggestion ServiceSuggestion? @relation(fields: [suggestionId], references: [id], onDelete: Cascade) - suggestionId Int? - description String - processed Boolean @default(false) - createdAt DateTime @default(now()) + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + action KarmaTransactionAction + points Int @default(0) + comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade) + commentId Int? + suggestion ServiceSuggestion? @relation(fields: [suggestionId], references: [id], onDelete: Cascade) + suggestionId Int? + description String + processed Boolean @default(false) + createdAt DateTime @default(now()) + grantedBy User? @relation("KarmaGrantedBy", fields: [grantedByUserId], references: [id], onDelete: SetNull) + grantedByUserId Int? @@index([createdAt]) @@index([userId]) @@index([processed]) @@index([suggestionId]) @@index([commentId]) + @@index([grantedByUserId]) } enum VerificationStepStatus { @@ -588,3 +610,17 @@ model ServiceUser { @@index([serviceId]) @@index([role]) } + +model Announcement { + id Int @id @default(autoincrement()) + title String + content String + type AnnouncementType + startDate DateTime + endDate DateTime? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@index([isActive, startDate, endDate]) +} diff --git a/web/prisma/triggers/01_karma_tx.sql b/web/prisma/triggers/01_karma_tx.sql index dcd30d9..f562dac 100644 --- a/web/prisma/triggers/01_karma_tx.sql +++ b/web/prisma/triggers/01_karma_tx.sql @@ -24,7 +24,7 @@ DROP FUNCTION IF EXISTS handle_suggestion_status_change(); CREATE OR REPLACE FUNCTION insert_karma_transaction( p_user_id INT, p_points INT, - p_action TEXT, + p_action "KarmaTransactionAction", p_comment_id INT, p_description TEXT, p_suggestion_id INT DEFAULT NULL @@ -65,7 +65,7 @@ BEGIN PERFORM insert_karma_transaction( NEW."authorId", 1, - 'comment_approved', + 'COMMENT_APPROVED', NEW.id, format('Your comment #comment-%s in %s has been approved!', NEW.id, @@ -86,7 +86,7 @@ BEGIN PERFORM insert_karma_transaction( NEW."authorId", 5, - 'comment_verified', + 'COMMENT_VERIFIED', NEW.id, format('Your comment #comment-%s in %s has been verified!', NEW.id, @@ -108,7 +108,7 @@ BEGIN PERFORM insert_karma_transaction( NEW."authorId", -10, - 'comment_spam', + 'COMMENT_SPAM', NEW.id, format('Your comment #comment-%s in %s has been marked as spam.', NEW.id, @@ -120,7 +120,7 @@ BEGIN PERFORM insert_karma_transaction( NEW."authorId", 10, - 'comment_spam_reverted', + 'COMMENT_SPAM_REVERTED', NEW.id, format('Your comment #comment-%s in %s is no longer marked as spam.', NEW.id, @@ -136,7 +136,7 @@ CREATE OR REPLACE FUNCTION handle_comment_vote_change() RETURNS TRIGGER AS $$ DECLARE karma_points INT; - vote_action TEXT; + vote_action "KarmaTransactionAction"; vote_description TEXT; comment_author_id INT; service_name TEXT; @@ -151,7 +151,7 @@ BEGIN IF TG_OP = 'INSERT' THEN -- New vote karma_points := CASE WHEN NEW.downvote THEN -1 ELSE 1 END; - vote_action := CASE WHEN NEW.downvote THEN 'comment_downvote' ELSE 'comment_upvote' END; + vote_action := CASE WHEN NEW.downvote THEN 'COMMENT_DOWNVOTE' ELSE 'COMMENT_UPVOTE' END; vote_description := format('Your comment #comment-%s in %s received %s', NEW."commentId", service_name, @@ -160,7 +160,7 @@ BEGIN ELSIF TG_OP = 'DELETE' THEN -- Removed vote karma_points := CASE WHEN OLD.downvote THEN 1 ELSE -1 END; - vote_action := 'comment_vote_removed'; + vote_action := 'COMMENT_VOTE_REMOVED'; vote_description := format('A vote was removed from your comment #comment-%s in %s', OLD."commentId", service_name); @@ -168,7 +168,7 @@ BEGIN ELSIF TG_OP = 'UPDATE' THEN -- Changed vote (from upvote to downvote or vice versa) karma_points := CASE WHEN NEW.downvote THEN -2 ELSE 2 END; - vote_action := CASE WHEN NEW.downvote THEN 'comment_downvote' ELSE 'comment_upvote' END; + vote_action := CASE WHEN NEW.downvote THEN 'COMMENT_DOWNVOTE' ELSE 'COMMENT_UPVOTE' END; vote_description := format('Your comment #comment-%s in %s vote changed to %s', NEW."commentId", service_name, @@ -243,7 +243,7 @@ BEGIN PERFORM insert_karma_transaction( NEW."userId", 10, - 'suggestion_approved', + 'SUGGESTION_APPROVED', NULL, -- p_comment_id (not applicable) format('Your suggestion for service ''%s'' has been approved!', service_name), NEW.id -- p_suggestion_id @@ -263,3 +263,24 @@ CREATE TRIGGER suggestion_status_change_trigger ON "ServiceSuggestion" FOR EACH ROW EXECUTE FUNCTION handle_suggestion_status_change(); + +-- Function to handle manual karma adjustments +CREATE OR REPLACE FUNCTION handle_manual_karma_adjustment() +RETURNS TRIGGER AS $$ +BEGIN + -- Only process MANUAL_ADJUSTMENT transactions that are not yet processed + IF NEW.processed = false AND NEW.action = 'MANUAL_ADJUSTMENT' THEN + -- Update user's total karma + PERFORM update_user_karma(NEW."userId", NEW.points); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create trigger for manual karma adjustments +CREATE TRIGGER manual_karma_adjustment_trigger + AFTER INSERT + ON "KarmaTransaction" + FOR EACH ROW + EXECUTE FUNCTION handle_manual_karma_adjustment(); diff --git a/web/src/actions/admin/announcement.ts b/web/src/actions/admin/announcement.ts new file mode 100644 index 0000000..6e2d4a6 --- /dev/null +++ b/web/src/actions/admin/announcement.ts @@ -0,0 +1,161 @@ +import { type Prisma, type PrismaClient, type AnnouncementType } from '@prisma/client' +import { ActionError } from 'astro:actions' +import { z } from 'zod' + +import { defineProtectedAction } from '../../lib/defineProtectedAction' +import { prisma as prismaInstance } from '../../lib/prisma' + +const prisma = prismaInstance as PrismaClient + +const selectAnnouncementReturnFields = { + id: true, + title: true, + content: true, + type: true, + startDate: true, + endDate: true, + isActive: true, + createdAt: true, + updatedAt: true, +} as const satisfies Prisma.AnnouncementSelect + +export const adminAnnouncementActions = { + create: defineProtectedAction({ + accept: 'form', + permissions: 'admin', + input: z.object({ + title: z.string().min(1, 'Title is required').max(255, 'Title must be less than 255 characters'), + content: z + .string() + .min(1, 'Content is required') + .max(1000, 'Content must be less than 1000 characters'), + type: z.enum(['INFO', 'WARNING', 'ALERT']), + startDate: z.coerce.date(), + endDate: z.coerce.date().nullable().optional(), + isActive: z.coerce.boolean().default(true), + }), + handler: async (input) => { + const announcement = await prisma.announcement.create({ + data: { + ...input, + endDate: input.endDate || null, + }, + select: selectAnnouncementReturnFields, + }) + + return { announcement } + }, + }), + + update: defineProtectedAction({ + accept: 'form', + permissions: 'admin', + input: z.object({ + id: z.coerce.number().int().positive(), + title: z.string().min(1, 'Title is required').max(255, 'Title must be less than 255 characters'), + content: z + .string() + .min(1, 'Content is required') + .max(1000, 'Content must be less than 1000 characters'), + type: z.enum(['INFO', 'WARNING', 'ALERT']), + startDate: z.coerce.date(), + endDate: z.coerce.date().nullable().optional(), + isActive: z.coerce.boolean().default(true), + }), + handler: async (input) => { + const announcement = await prisma.announcement.findUnique({ + where: { + id: input.id, + }, + select: { + id: true, + }, + }) + + if (!announcement) { + throw new ActionError({ + code: 'BAD_REQUEST', + message: 'Announcement not found', + }) + } + + const updatedAnnouncement = await prisma.announcement.update({ + where: { id: announcement.id }, + data: { + ...input, + endDate: input.endDate || null, + }, + select: selectAnnouncementReturnFields, + }) + + return { updatedAnnouncement } + }, + }), + + delete: defineProtectedAction({ + accept: 'form', + permissions: 'admin', + input: z.object({ + id: z.coerce.number().int().positive(), + }), + handler: async (input) => { + const announcement = await prisma.announcement.findUnique({ + where: { + id: input.id, + }, + select: { + id: true, + }, + }) + + if (!announcement) { + throw new ActionError({ + code: 'BAD_REQUEST', + message: 'Announcement not found', + }) + } + + await prisma.announcement.delete({ + where: { id: announcement.id }, + }) + + return { success: true } + }, + }), + + toggleActive: defineProtectedAction({ + accept: 'form', + permissions: 'admin', + input: z.object({ + id: z.coerce.number().int().positive(), + isActive: z.coerce.boolean(), + }), + handler: async (input) => { + const announcement = await prisma.announcement.findUnique({ + where: { + id: input.id, + }, + select: { + id: true, + }, + }) + + if (!announcement) { + throw new ActionError({ + code: 'BAD_REQUEST', + message: 'Announcement not found', + }) + } + + const updatedAnnouncement = await prisma.announcement.update({ + where: { id: announcement.id }, + data: { + isActive: input.isActive, + }, + select: selectAnnouncementReturnFields, + }) + + return { updatedAnnouncement } + }, + }), +} diff --git a/web/src/actions/admin/index.ts b/web/src/actions/admin/index.ts index 14dff5c..68a14c9 100644 --- a/web/src/actions/admin/index.ts +++ b/web/src/actions/admin/index.ts @@ -1,3 +1,4 @@ +import { adminAnnouncementActions } from './announcement' import { adminAttributeActions } from './attribute' import { adminEventActions } from './event' import { adminServiceActions } from './service' @@ -7,6 +8,7 @@ import { verificationStep } from './verificationStep' export const adminActions = { attribute: adminAttributeActions, + announcement: adminAnnouncementActions, event: adminEventActions, service: adminServiceActions, serviceSuggestions: adminServiceSuggestionActions, diff --git a/web/src/actions/admin/user.ts b/web/src/actions/admin/user.ts index 4ccd226..c81fb41 100644 --- a/web/src/actions/admin/user.ts +++ b/web/src/actions/admin/user.ts @@ -54,11 +54,8 @@ export const adminUserActions = { .nullable() .default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing .transform((val) => val || null), - picture: z.string().max(255, 'Picture URL must be less than 255 characters').nullable().default(null), pictureFile: z.instanceof(File).optional(), - verifier: z.boolean().default(false), - admin: z.boolean().default(false), - spammer: z.boolean().default(false), + role: z.enum(['admin', 'verifier', 'spammer']), verifiedLink: z .string() .url('Invalid URL') @@ -72,7 +69,7 @@ export const adminUserActions = { .default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing .transform((val) => val || null), }), - handler: async ({ id, picture, pictureFile, ...valuesToUpdate }) => { + handler: async ({ id, pictureFile, ...valuesToUpdate }) => { const user = await prisma.user.findUnique({ where: { id, @@ -89,10 +86,10 @@ export const adminUserActions = { }) } - let pictureUrl = picture ?? null - if (pictureFile && pictureFile.size > 0) { - pictureUrl = await saveFileLocally(pictureFile, pictureFile.name, 'users/pictures/') - } + const pictureUrl = + pictureFile && pictureFile.size > 0 + ? await saveFileLocally(pictureFile, pictureFile.name, 'users/pictures/') + : null const updatedUser = await prisma.user.update({ where: { id: user.id }, @@ -293,10 +290,9 @@ export const adminUserActions = { input: z.object({ userId: z.coerce.number().int().positive(), points: z.coerce.number().int(), - action: z.string().min(1, 'Action is required'), description: z.string().min(1, 'Description is required'), }), - handler: async (input) => { + handler: async (input, context) => { // Check if the user exists const user = await prisma.user.findUnique({ where: { id: input.userId }, @@ -314,9 +310,9 @@ export const adminUserActions = { data: { userId: input.userId, points: input.points, - action: input.action, + action: 'MANUAL_ADJUSTMENT', description: input.description, - processed: true, + grantedByUserId: context.locals.user.id, }, }) }, diff --git a/web/src/components/AnnouncementBanner.astro b/web/src/components/AnnouncementBanner.astro new file mode 100644 index 0000000..1634be7 --- /dev/null +++ b/web/src/components/AnnouncementBanner.astro @@ -0,0 +1,82 @@ +--- +import { Icon } from 'astro-icon/components' +import { Markdown } from 'astro-remote' + +import type { AnnouncementType } from '@prisma/client' + +export type Announcement = { + id: number + title: string + content: string + type: AnnouncementType + startDate: Date + endDate: Date | null + isActive: boolean +} + +export type Props = { + announcements: Announcement[] +} + +const { announcements } = Astro.props + +// Get icon and class based on announcement type +const getTypeInfo = (type: AnnouncementType) => { + switch (type) { + case 'INFO': + return { + icon: 'ri:information-line', + containerClass: 'bg-blue-900/40 border-blue-500/30', + titleClass: 'text-blue-400', + contentClass: 'text-blue-300', + } + case 'WARNING': + return { + icon: 'ri:alert-line', + containerClass: 'bg-yellow-900/40 border-yellow-500/30', + titleClass: 'text-yellow-400', + contentClass: 'text-yellow-300', + } + case 'ALERT': + return { + icon: 'ri:error-warning-line', + containerClass: 'bg-red-900/40 border-red-500/30', + titleClass: 'text-red-400', + contentClass: 'text-red-300', + } + default: + return { + icon: 'ri:information-line', + containerClass: 'bg-blue-900/40 border-blue-500/30', + titleClass: 'text-blue-400', + contentClass: 'text-blue-300', + } + } +} +--- + +{ + announcements.length > 0 && ( +
+ {announcements.map((announcement) => { + const typeInfo = getTypeInfo(announcement.type) + + return ( +
+ +
+ + {announcement.title} + + + + +
+
+ ) + })} +
+ ) +} diff --git a/web/src/components/InputCardGroup.astro b/web/src/components/InputCardGroup.astro index bc579d3..46d0917 100644 --- a/web/src/components/InputCardGroup.astro +++ b/web/src/components/InputCardGroup.astro @@ -9,29 +9,33 @@ import InputWrapper from './InputWrapper.astro' import type { MarkdownString } from '../lib/markdown' import type { ComponentProps } from 'astro/types' -type Props = Omit, 'children' | 'inputId'> & { +type Props = Omit< + ComponentProps, + 'children' | 'inputId' +> & { options: { label: string value: string icon?: string iconClass?: string description?: MarkdownString + disabled?: boolean }[] disabled?: boolean - selectedValue?: string + selectedValue?: Multiple extends true ? string[] : string cardSize?: 'lg' | 'md' | 'sm' iconSize?: 'md' | 'sm' - multiple?: boolean + multiple?: Multiple } const { options, disabled, - selectedValue, + selectedValue = undefined as string[] | string | undefined, cardSize = 'sm', iconSize = 'sm', class: className, - multiple, + multiple = false as boolean, ...wrapperProps } = Astro.props @@ -40,6 +44,7 @@ const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`) const hasError = !!wrapperProps.error && wrapperProps.error.length > 0 --- +{/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */}
0 'has-checked:border-green-700 has-checked:bg-green-700/20 has-checked:ring-1 has-checked:ring-green-700', multiple && 'has-focus-visible:border-day-300 has-focus-visible:ring-2 has-focus-visible:ring-green-700 has-focus-visible:ring-offset-1', - disabled && 'cursor-not-allowed opacity-50' + 'has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50' )} > 0 type={multiple ? 'checkbox' : 'radio'} name={wrapperProps.name} value={option.value} - checked={selectedValue === option.value} + checked={ + Array.isArray(selectedValue) + ? selectedValue.includes(option.value) + : selectedValue === option.value + } class="peer sr-only" - disabled={disabled} + disabled={disabled || option.disabled} />
{option.icon && ( diff --git a/web/src/components/InputSelect.astro b/web/src/components/InputSelect.astro new file mode 100644 index 0000000..6800c9e --- /dev/null +++ b/web/src/components/InputSelect.astro @@ -0,0 +1,48 @@ +--- +import { omit } from 'lodash-es' + +import { cn } from '../lib/cn' +import { baseInputClassNames } from '../lib/formInputs' + +import InputWrapper from './InputWrapper.astro' + +import type { ComponentProps, HTMLAttributes } from 'astro/types' + +type Props = Omit, 'children' | 'inputId' | 'required'> & { + options: { + label: string + value: string + disabled?: boolean + }[] + selectProps?: Omit, 'name'> +} + +const { options, selectProps, ...wrapperProps } = Astro.props + +const inputId = selectProps?.id ?? Astro.locals.makeId(`input-${wrapperProps.name}`) +const hasError = !!wrapperProps.error && wrapperProps.error.length > 0 +--- + + + + diff --git a/web/src/components/InputTextArea.astro b/web/src/components/InputTextArea.astro index 094e564..05fe4c2 100644 --- a/web/src/components/InputTextArea.astro +++ b/web/src/components/InputTextArea.astro @@ -1,44 +1,36 @@ --- +import { omit } from 'lodash-es' + import { cn } from '../lib/cn' import { baseInputClassNames } from '../lib/formInputs' import InputWrapper from './InputWrapper.astro' -import type { ComponentProps } from 'astro/types' +import type { ComponentProps, HTMLAttributes } from 'astro/types' -type Props = Omit, 'children' | 'inputId'> & { +type Props = Omit, 'children' | 'inputId' | 'required'> & { + inputProps?: Omit, 'name'> value?: string - placeholder?: string - disabled?: boolean - autofocus?: boolean - rows?: number - maxlength?: number } -const { value, placeholder, maxlength, disabled, autofocus, rows = 3, ...wrapperProps } = Astro.props +const { inputProps, value, ...wrapperProps } = Astro.props -const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`) +const inputId = inputProps?.id ?? Astro.locals.makeId(`input-${wrapperProps.name}`) const hasError = !!wrapperProps.error && wrapperProps.error.length > 0 --- -{/* eslint-disable astro/jsx-a11y/no-autofocus */} - - + {value} diff --git a/web/src/components/InputWrapper.astro b/web/src/components/InputWrapper.astro index ac092a4..a447fcd 100644 --- a/web/src/components/InputWrapper.astro +++ b/web/src/components/InputWrapper.astro @@ -66,7 +66,7 @@ const hasError = !!error && error.length > 0 { !!description && ( -
+
) diff --git a/web/src/constants/karmaTransactionActions.ts b/web/src/constants/karmaTransactionActions.ts new file mode 100644 index 0000000..39d9b57 --- /dev/null +++ b/web/src/constants/karmaTransactionActions.ts @@ -0,0 +1,86 @@ +import { makeHelpersForOptions } from '../lib/makeHelpersForOptions' +import { transformCase } from '../lib/strings' + +import type { KarmaTransactionAction } from '@prisma/client' + +type KarmaTransactionActionInfo = { + value: T + slug: string + label: string + icon: string +} + +export const { + dataArray: karmaTransactionActions, + dataObject: karmaTransactionActionsById, + getFn: getKarmaTransactionActionInfo, + getFnSlug: getKarmaTransactionActionInfoBySlug, + zodEnumBySlug: karmaTransactionActionsZodEnumBySlug, + zodEnumById: karmaTransactionActionsZodEnumById, + keyToSlug: karmaTransactionActionIdToSlug, + slugToKey: karmaTransactionActionSlugToId, +} = makeHelpersForOptions( + 'value', + (value): KarmaTransactionActionInfo => ({ + value, + slug: value ? value.toLowerCase() : '', + label: value ? transformCase(value.replace('_', ' '), 'title') : String(value), + icon: 'ri:question-line', + }), + [ + { + value: 'COMMENT_APPROVED', + slug: 'comment-approved', + label: 'Comment approved', + icon: 'ri:check-line', + }, + { + value: 'COMMENT_VERIFIED', + slug: 'comment-verified', + label: 'Comment verified', + icon: 'ri:verified-badge-line', + }, + { + value: 'COMMENT_SPAM', + slug: 'comment-spam', + label: 'Comment marked as SPAM', + icon: 'ri:spam-2-line', + }, + { + value: 'COMMENT_SPAM_REVERTED', + slug: 'comment-spam-reverted', + label: 'Comment SPAM reverted', + icon: 'ri:spam-2-line', + }, + { + value: 'COMMENT_UPVOTE', + slug: 'comment-upvote', + label: 'Comment upvoted', + icon: 'ri:thumb-up-line', + }, + { + value: 'COMMENT_DOWNVOTE', + slug: 'comment-downvote', + label: 'Comment downvoted', + icon: 'ri:thumb-down-line', + }, + { + value: 'COMMENT_VOTE_REMOVED', + slug: 'comment-vote-removed', + label: 'Comment vote removed', + icon: 'ri:thumb-up-line', + }, + { + value: 'SUGGESTION_APPROVED', + slug: 'suggestion-approved', + label: 'Suggestion approved', + icon: 'ri:lightbulb-line', + }, + { + value: 'MANUAL_ADJUSTMENT', + slug: 'manual-adjustment', + label: 'Manual adjustment', + icon: 'ri:gift-line', + }, + ] as const satisfies KarmaTransactionActionInfo[] +) diff --git a/web/src/pages/account/index.astro b/web/src/pages/account/index.astro index a8ab0b4..78c15c1 100644 --- a/web/src/pages/account/index.astro +++ b/web/src/pages/account/index.astro @@ -9,6 +9,7 @@ import BadgeSmall from '../../components/BadgeSmall.astro' import Button from '../../components/Button.astro' import TimeFormatted from '../../components/TimeFormatted.astro' import Tooltip from '../../components/Tooltip.astro' +import { getKarmaTransactionActionInfo } from '../../constants/karmaTransactionActions' import { karmaUnlocks, karmaUnlocksById } from '../../constants/karmaUnlocks' import { SUPPORT_EMAIL } from '../../constants/project' import { getServiceSuggestionStatusInfo } from '../../constants/serviceSuggestionStatus' @@ -61,6 +62,12 @@ const user = await Astro.locals.banners.try('user', async () => { action: true, description: true, createdAt: true, + grantedBy: { + select: { + name: true, + displayName: true, + }, + }, comment: { select: { id: true, @@ -864,24 +871,41 @@ if (!user) return Astro.rewrite('/404') - {user.karmaTransactions.map((transaction) => ( - - {transaction.action} - {transaction.description} - = 0 ? 'text-green-400' : 'text-red-400' - )} - > - {transaction.points >= 0 && '+'} - {transaction.points} - - - {new Date(transaction.createdAt).toLocaleDateString()} - - - ))} + {user.karmaTransactions.map((transaction) => { + const actionInfo = getKarmaTransactionActionInfo(transaction.action) + return ( + + + + + {actionInfo.label} + {transaction.action === 'MANUAL_ADJUSTMENT' && transaction.grantedBy && ( + + {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} + by {transaction.grantedBy.displayName || transaction.grantedBy.name} + + )} + + + {transaction.description} + = 0 ? 'text-green-400' : 'text-red-400' + )} + > + {transaction.points >= 0 && '+'} + {transaction.points} + + + {new Date(transaction.createdAt).toLocaleDateString()} + + + ) + })}
diff --git a/web/src/pages/admin/announcements/index.astro b/web/src/pages/admin/announcements/index.astro new file mode 100644 index 0000000..e8cdf58 --- /dev/null +++ b/web/src/pages/admin/announcements/index.astro @@ -0,0 +1,808 @@ +--- +import { Icon } from 'astro-icon/components' +import { actions, isInputError } from 'astro:actions' +import { z } from 'astro:schema' + +import { adminAnnouncementActions } from '../../../actions/admin/announcement' +import SortArrowIcon from '../../../components/SortArrowIcon.astro' +import TimeFormatted from '../../../components/TimeFormatted.astro' +import Tooltip from '../../../components/Tooltip.astro' +import BaseLayout from '../../../layouts/BaseLayout.astro' +import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters' +import { prisma } from '../../../lib/prisma' + +import type { AnnouncementType, Prisma } from '@prisma/client' + +const { data: filters } = zodParseQueryParamsStoringErrors( + { + 'sort-by': z + .enum(['title', 'type', 'startDate', 'endDate', 'isActive', 'createdAt']) + .default('createdAt'), + 'sort-order': z.enum(['asc', 'desc']).default('desc'), + search: z.string().optional(), + type: z.enum(['INFO', 'WARNING', 'ALERT']).optional(), + status: z.enum(['active', 'inactive']).optional(), + }, + Astro +) + +// Set up Prisma orderBy with correct typing +const prismaOrderBy = { + [filters['sort-by']]: filters['sort-order'] === 'asc' ? 'asc' : 'desc', +} as const satisfies Prisma.AnnouncementOrderByWithRelationInput + +// Build where clause based on filters +const whereClause: Prisma.AnnouncementWhereInput = {} + +if (filters.search) { + whereClause.OR = [ + { title: { contains: filters.search, mode: 'insensitive' } }, + { content: { contains: filters.search, mode: 'insensitive' } }, + ] +} + +if (filters.type) { + whereClause.type = filters.type as AnnouncementType +} + +if (filters.status) { + whereClause.isActive = filters.status === 'active' +} + +// Retrieve announcements from the database +const announcements = await prisma.announcement.findMany({ + where: whereClause, + orderBy: prismaOrderBy, +}) + +// Helper for generating sort URLs +const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => { + const currentSortBy = filters['sort-by'] + const currentSortOrder = filters['sort-order'] + const newSortOrder = currentSortBy === slug && currentSortOrder === 'asc' ? 'desc' : 'asc' + const searchParams = new URLSearchParams(Astro.url.search) + searchParams.set('sort-by', slug) + searchParams.set('sort-order', newSortOrder) + return `/admin/announcements?${searchParams.toString()}` +} + +// Get type badge class based on announcement type +const getTypeBadgeClass = (type: AnnouncementType) => { + switch (type) { + case 'INFO': + return 'bg-blue-900/30 text-blue-400' + case 'WARNING': + return 'bg-yellow-900/30 text-yellow-400' + case 'ALERT': + return 'bg-red-900/30 text-red-400' + default: + return 'bg-zinc-900/30 text-zinc-400' + } +} + +// Current date for form min values +const currentDate = new Date().toISOString().slice(0, 16) // Format: YYYY-MM-DDThh:mm + +// Default new announcement +const newAnnouncement = { + title: '', + content: '', + type: 'INFO' as const, + startDate: currentDate, + endDate: '', + isActive: true, +} + +// Get action results +const createResult = Astro.getActionResult(adminAnnouncementActions.create) +const updateResult = Astro.getActionResult(adminAnnouncementActions.update) +const deleteResult = Astro.getActionResult(adminAnnouncementActions.delete) +const toggleResult = Astro.getActionResult(adminAnnouncementActions.toggleActive) + +// Add success messages to banners +Astro.locals.banners.addIfSuccess(createResult, 'Announcement created successfully!') +Astro.locals.banners.addIfSuccess(updateResult, 'Announcement updated successfully!') +Astro.locals.banners.addIfSuccess(deleteResult, 'Announcement deleted successfully!') +Astro.locals.banners.addIfSuccess( + toggleResult, + (data) => `Announcement ${data.updatedAnnouncement.isActive ? 'activated' : 'deactivated'} successfully!` +) + +// Add error messages to banners +if (createResult?.error) { + const err = createResult.error + Astro.locals.banners.add({ + uiMessage: err.message, + type: 'error', + origin: 'action', + error: err, + }) +} +if (updateResult?.error) { + const err = updateResult.error + Astro.locals.banners.add({ + uiMessage: err.message, + type: 'error', + origin: 'action', + error: err, + }) +} +if (deleteResult?.error) { + const err = deleteResult.error + Astro.locals.banners.add({ + uiMessage: err.message, + type: 'error', + origin: 'action', + error: err, + }) +} +if (toggleResult?.error) { + const err = toggleResult.error + Astro.locals.banners.add({ + uiMessage: err.message, + type: 'error', + origin: 'action', + error: err, + }) +} +--- + + +
+

Announcement Management

+
+ {announcements.length} announcements +
+
+ +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+ + +
+ + + +
+ + + +
+
+

Edit Announcement

+ +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + + +
+
+
+
+
+ + + +
+
+

Confirm Deletion

+ +
+ +

+ Are you sure you want to delete this announcement? This action cannot be undone. +

+ +
+ +
+ + +
+
+
+
+ + +
+
+

Announcements List

+
+ Scroll horizontally to see more → +
+
+
+
+ + + + + + + + + + + + + + { + announcements.length === 0 && ( + + + + ) + } + { + announcements.map((announcement) => ( + + + + + + + + + + )) + } + +
+ + Title + + + + + Type + + + + + Start Date + + + + + End Date + + + + + Status + + + + + Created At + + + + Actions +
+ +

No announcements found matching your criteria.

+

Try adjusting your search or filters, or create a new announcement.

+
+
{announcement.title}
+
{announcement.content}
+
+ + {announcement.type} + + + + + {announcement.endDate ? ( + + ) : ( + + )} + + + {announcement.isActive ? 'Active' : 'Inactive'} + + + + +
+ + + + +
+ + + +
+ +
+ + +
+
+
+
+
+
+
+ + + + diff --git a/web/src/pages/admin/index.astro b/web/src/pages/admin/index.astro index bb2124b..49592dc 100644 --- a/web/src/pages/admin/index.astro +++ b/web/src/pages/admin/index.astro @@ -43,6 +43,12 @@ const adminLinks: AdminLink[] = [ href: '/admin/service-suggestions', description: 'Review and manage service suggestions', }, + { + icon: 'ri:megaphone-line', + title: 'Announcements', + href: '/admin/announcements', + description: 'Manage site announcements', + }, ] --- diff --git a/web/src/pages/admin/users/[username].astro b/web/src/pages/admin/users/[username].astro index 9008650..82da00c 100644 --- a/web/src/pages/admin/users/[username].astro +++ b/web/src/pages/admin/users/[username].astro @@ -1,12 +1,20 @@ --- import { Icon } from 'astro-icon/components' import { actions, isInputError } from 'astro:actions' +import { Image } from 'astro:assets' -import Tooltip from '../../../components/Tooltip.astro' +import BadgeSmall from '../../../components/BadgeSmall.astro' +import Button from '../../../components/Button.astro' +import InputCardGroup from '../../../components/InputCardGroup.astro' +import InputImageFile from '../../../components/InputImageFile.astro' +import InputSelect from '../../../components/InputSelect.astro' +import InputSubmitButton from '../../../components/InputSubmitButton.astro' +import InputText from '../../../components/InputText.astro' +import InputTextArea from '../../../components/InputTextArea.astro' +import TimeFormatted from '../../../components/TimeFormatted.astro' +import { getServiceUserRoleInfo, serviceUserRoles } from '../../../constants/serviceUserRoles' import BaseLayout from '../../../layouts/BaseLayout.astro' import { prisma } from '../../../lib/prisma' -import { transformCase } from '../../../lib/strings' -import { timeAgo } from '../../../lib/timeAgo' const { username } = Astro.params @@ -56,6 +64,8 @@ const [user, allServices] = await Astro.locals.banners.tryMany([ select: { id: true, name: true, + displayName: true, + picture: true, }, }, }, @@ -105,543 +115,321 @@ const [user, allServices] = await Astro.locals.banners.tryMany([ if (!user) return Astro.rewrite('/404') --- - -
-
-

User Profile: {user.name}

- - - Back to Users - + +
+ { + !!user.picture && ( + + ) + } +

+ {user.displayName ? `${user.displayName} (${user.name})` : user.name} +

+ +
+ {user.admin && } + {user.verified && } + {user.verifier && } + {user.spammer && }
-
-
- { - user.picture ? ( - - ) : ( -
- {user.name.charAt(0) || 'A'} -
- ) - } -
-

{user.name}

-
- { - user.admin && ( - - admin - - ) - } - { - user.verified && ( - - verified - - ) - } - { - user.verifier && ( - - verifier - - ) - } -
-
-
- -
- - -
- - - { - updateInputErrors.name && ( -

{updateInputErrors.name.join(', ')}

- ) - } -
- -
- - - { - Array.isArray(updateInputErrors.displayName) && updateInputErrors.displayName.length > 0 && ( -

{updateInputErrors.displayName.join(', ')}

- ) - } -
- -
- - - { - updateInputErrors.link && ( -

{updateInputErrors.link.join(', ')}

- ) - } -
- -
- - - { - updateInputErrors.picture && ( -

{updateInputErrors.picture.join(', ')}

- ) - } -
- -
- - -

- Upload a square image for best results. Supported formats: JPG, PNG, WebP, AVIF, JXL. Max size: - 5MB. -

-
- -
- - - - - Verified - - - - - -
- - { - updateInputErrors.admin && ( -

{updateInputErrors.admin.join(', ')}

- ) - } - { - updateInputErrors.verifier && ( -

{updateInputErrors.verifier.join(', ')}

- ) - } - { - updateInputErrors.spammer && ( -

{updateInputErrors.spammer.join(', ')}

- ) - } - -
- - - { - updateInputErrors.verifiedLink && ( -

{updateInputErrors.verifiedLink.join(', ')}

- ) - } -
- -
- -
-
+
+
+
+
-
-

Internal Notes

+
+

Edit profile

- { - user.internalNotes.length === 0 ? ( -

No internal notes yet.

- ) : ( -
- {user.internalNotes.map((note) => ( -
-
-
- - {note.addedByUser ? note.addedByUser.name : 'System'} - - - {transformCase(timeAgo.format(note.createdAt, 'twitter-minute-now'), 'sentence')} - -
+ -
- +
+ - - - - -
+ + + + + +
+ + + + v !== null)} + required + cardSize="sm" + iconSize="sm" + multiple + error={updateInputErrors.role} + /> + + + + +
+

Internal Notes

+ + { + user.internalNotes.length === 0 ? ( +

No internal notes yet.

+ ) : ( +
+ {user.internalNotes.map((note) => ( +
+
+
+ {!!note.addedByUser?.picture && ( + + )} + + {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} + {note.addedByUser ? note.addedByUser.displayName || note.addedByUser.name : 'System'} + +
-
-

{note.content}

-
- + +
- ))} -
- ) - } -
- - - -
-
+
+

{note.content}

+
-
-

Service Affiliations

+
+ ))} +
+ ) + } - { - user.serviceAffiliations.length === 0 ? ( -

No service affiliations yet.

- ) : ( -
- {user.serviceAffiliations.map((affiliation) => ( -
-
-
- - {affiliation.service.name} - - - {affiliation.role.toLowerCase()} - -
-
- - {transformCase(timeAgo.format(affiliation.createdAt, 'twitter-minute-now'), 'sentence')} - -
-
+
+

Add Note

+ + + + + +
+ +
+

Service Affiliations

+ + { + user.serviceAffiliations.length === 0 ? ( +

No service affiliations yet.

+ ) : ( +
+ {user.serviceAffiliations.map((affiliation) => { + const roleInfo = getServiceUserRoleInfo(affiliation.role) + return ( + - ))} -
- ) - } - -
- - -
-
- - -
- -
- - -
+ ) + })}
+ ) + } -
- -
-
-
- -
-

Add Karma Transaction

+

Add Affiliation

-
- + -
-
- - -
+ ({ + label: service.name, + value: service.id.toString(), + }))} + selectProps={{ required: true }} + /> -
- - -
-
+ ({ + label: role.label, + value: role.value, + icon: role.icon, + }))} + required + cardSize="sm" + iconSize="sm" + /> -
- - -
+ + +
-
- -
- - -
+
+

Grant/Remove Karma

+ + + + + + + + +
- - diff --git a/web/src/pages/admin/users/index.astro b/web/src/pages/admin/users/index.astro index ec97374..7bc26e9 100644 --- a/web/src/pages/admin/users/index.astro +++ b/web/src/pages/admin/users/index.astro @@ -322,6 +322,7 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => { diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index 8d950d7..d14d5bf 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -4,6 +4,7 @@ import { z } from 'astro:schema' import { groupBy, orderBy } from 'lodash-es' import seedrandom from 'seedrandom' +import AnnouncementBanner from '../components/AnnouncementBanner.astro' import Button from '../components/Button.astro' import Pagination from '../components/Pagination.astro' import ServiceFiltersPill from '../components/ServiceFiltersPill.astro' @@ -501,7 +502,18 @@ const filtersOptions = { export type ServicesFiltersOptions = typeof filtersOptions -// +const currentDate = new Date() +const activeAnnouncements = await prisma.announcement.findMany({ + where: { + isActive: true, + startDate: { lte: currentDate }, + OR: [{ endDate: null }, { endDate: { gt: currentDate } }], + }, + orderBy: [ + { type: 'desc' }, // ALERT first, then WARNING, then INFO + { createdAt: 'desc' }, + ], +}) --- + + +
{ diff --git a/web/src/pages/service-suggestion/edit.astro b/web/src/pages/service-suggestion/edit.astro index 8eda41d..0e32fe5 100644 --- a/web/src/pages/service-suggestion/edit.astro +++ b/web/src/pages/service-suggestion/edit.astro @@ -88,8 +88,11 @@ if (!service) return Astro.rewrite('/404') label="Note for Moderators" name="notes" value={params.notes} - rows={10} - placeholder="List the changes you want us to make to the service. Example: 'Add X, Y and Z attributes' 'Monero is accepted'. Provide supporting evidence." + inputProps={{ + rows: 10, + placeholder: + 'List the changes you want us to make to the service. Example: "Add X, Y and Z attributes" "Monero is accepted". Provide supporting evidence.', + }} error={inputErrors.notes} /> diff --git a/web/src/pages/service-suggestion/new.astro b/web/src/pages/service-suggestion/new.astro index a987c96..1878b7e 100644 --- a/web/src/pages/service-suggestion/new.astro +++ b/web/src/pages/service-suggestion/new.astro @@ -184,31 +184,40 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([ label="Description" name="description" id="description" - required - maxlength={SUGGESTION_DESCRIPTION_MAX_LENGTH} + inputProps={{ + required: true, + maxlength: SUGGESTION_DESCRIPTION_MAX_LENGTH, + }} error={inputErrors.description} /> @@ -276,7 +285,9 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([ name="notes" id="notes" error={inputErrors.notes} - maxlength={SUGGESTION_NOTES_MAX_LENGTH} + inputProps={{ + maxlength: SUGGESTION_NOTES_MAX_LENGTH, + }} /> diff --git a/web/src/pages/u/[username].astro b/web/src/pages/u/[username].astro index ad01bc7..a6af27c 100644 --- a/web/src/pages/u/[username].astro +++ b/web/src/pages/u/[username].astro @@ -1,13 +1,17 @@ --- import { Icon } from 'astro-icon/components' +import { actions } from 'astro:actions' import { Picture } from 'astro:assets' import { sortBy } from 'lodash-es' import defaultServiceImage from '../../assets/fallback-service-image.jpg' import AdminOnly from '../../components/AdminOnly.astro' import BadgeSmall from '../../components/BadgeSmall.astro' +import InputSubmitButton from '../../components/InputSubmitButton.astro' +import InputTextArea from '../../components/InputTextArea.astro' import TimeFormatted from '../../components/TimeFormatted.astro' import Tooltip from '../../components/Tooltip.astro' +import { getKarmaTransactionActionInfo } from '../../constants/karmaTransactionActions' import { karmaUnlocks } from '../../constants/karmaUnlocks' import { SUPPORT_EMAIL } from '../../constants/project' import { getServiceSuggestionStatusInfo } from '../../constants/serviceSuggestionStatus' @@ -57,6 +61,12 @@ const user = await Astro.locals.banners.try('user', async () => { action: true, description: true, createdAt: true, + grantedBy: { + select: { + name: true, + displayName: true, + }, + }, comment: { select: { id: true, @@ -140,6 +150,21 @@ const user = await Astro.locals.banners.try('user', async () => { }, orderBy: [{ role: 'asc' }, { service: { name: 'asc' } }], }, + internalNotes: { + select: { + id: true, + content: true, + createdAt: true, + addedByUser: { + select: { + name: true, + displayName: true, + picture: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }, }, }) ) @@ -255,6 +280,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id @@ -453,6 +479,58 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
+ +
+
+

+ Internal Notes (Admin only) +

+
+ { + user.internalNotes.length === 0 ? ( +

No internal notes yet.

+ ) : ( +
+ {user.internalNotes.map((note) => ( +
+
+ {!!note.addedByUser?.picture && ( + + )} + + {note.addedByUser ? (note.addedByUser.displayName ?? note.addedByUser.name) : 'System'} + + + + +
+
{note.content}
+
+ ))} +
+ ) + } + +
+ + + + + +
+
+

@@ -879,29 +957,46 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id - {user.karmaTransactions.map((transaction) => ( - - {transaction.action} - {transaction.description} - = 0 ? 'text-green-400' : 'text-red-400' - )} - > - {transaction.points >= 0 && '+'} - {transaction.points} - - - - - - ))} + {user.karmaTransactions.map((transaction) => { + const actionInfo = getKarmaTransactionActionInfo(transaction.action) + return ( + + + + + {actionInfo.label} + {transaction.action === 'MANUAL_ADJUSTMENT' && transaction.grantedBy && ( + + {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} + by {transaction.grantedBy.displayName || transaction.grantedBy.name} + + )} + + + {transaction.description} + = 0 ? 'text-green-400' : 'text-red-400' + )} + > + {transaction.points >= 0 && '+'} + {transaction.points} + + + + + + ) + })}