# Palmy.io - AI Development Guide

## Project Overview

Palmy is "The Engagement Engine for Live Events" - an event management platform with two user roles: **Organizers** (event hosts) and **Users** (participants). Organizers create projects (contests, drawings, games, fanphotos, fundraisers) that users participate in during live events.

## Tech Stack

- **Framework**: Nuxt 4 + Vue 3 (Composition API, `<script setup lang="ts">`)
- **UI**: @nuxt/ui v4 + Tailwind CSS v4
- **Database**: Prisma + PostgreSQL (adapter: `@prisma/adapter-pg`)
- **Auth**: @nuxtjs/supabase (JWT in `event.context.user`)
- **Storage**: AWS S3 (`s3Upload`, `s3Delete` from `server/utils/s3Actions.post.ts`)
- **Payment**: Stripe
- **AI**: OpenAI (for generating MCQs, bingo cards)
- **Package Manager**: pnpm

## Project Structure

```
app/
  components/
    org/                  # Organizer components
      contest/form.vue    # Reusable form (defineModel pattern)
      contest/view.vue    # Preview/details view
      drawing/form.vue
      drawing/view.vue
      game/form.vue
      game/view.vue
      fanphoto/form.vue
      fanphoto/view.vue
      fundraiser/form.vue
      fundraiser/view.vue
      deleteModal.vue     # Shared delete modal
      header.vue          # Page header
      noData.vue          # Empty state
      projects/headerMenu.vue  # Status/edit/delete dropdown
      sponsorSelect.vue   # Sponsor + coupon multi-select
    photoUpload.vue       # Photo upload with crop/preview
    ui/                   # Wrappers around @nuxt/ui components
  pages/
    organizer/            # layout: 'organizer'
      [category]/add.vue        # Create page
      [category]/[id]/edit.vue  # Edit page (useLazyFetch + form component)
      [category]/[id]/details.vue  # View page
      [category]/[id]/manage.vue   # Manage entries/participants
      projects/index.vue  # All projects list
      settings/           # Profile, password
    user/                 # layout: 'user' (sidebar)
    live/[user]/          # Live display pages (720p, xs, qr)
    auth/                 # layout: 'default'
  composables/
    data.ts               # Options: contestOptions, gameOptions, drawingOptions, prizeOptions, distributionOptions, resultsOptions
    utils.ts              # formatDate(), formatDateTime(), imgSrc()
    useIcons.ts           # Icon name constants
    useAuth.ts            # Auth helpers
    api/organizer/        # API helper functions (save, update, delete for each category)
  layouts/
    organizer.vue         # Organizer dashboard layout
    user.vue              # User layout (sidebar nav)
    default.vue           # Auth/public pages

server/
  routes/api/
    organizer/            # Organizer APIs (auth required)
      [category]/[id]/view.ts      # GET data for edit page (returns apiResponse({result}))
      [category]/[id]/index.ts     # GET for details, POST for update, PUT for status, DELETE
      [category]/[id]/manage.ts   # GET participants/entries
      [category]/index.post.ts     # Create new project
    admin/                # Admin APIs
    user/                 # User-facing APIs
    live/                 # Live display APIs
    auth/                 # Authentication
  utils/
    apiResponse.ts        # Standard API response wrapper
    prisma.ts             # PrismaClient singleton (auto-imported as `prisma`)
    s3Actions.post.ts     # s3Upload(file, userId), s3Delete(key)
    supabase.ts           # Supabase admin client
  middleware/
    auth.ts               # JWT validation, sets event.context.user

prisma/
  schema.prisma           # Database schema
  generated/
    client/               # PrismaClient
    models/               # TypeScript types per model (import from '~~/prisma/generated/models')
```

## Project Categories

Five project types, each with: add, edit, details, manage pages + form component + view component:

| Category    | Form Fields                                                    |
|-------------|----------------------------------------------------------------|
| contest     | name, type, contests[{question, image, answers[]}], distribution, result, delayed, liveTitle, qrTitle, qrSubTitle, teamTag, eventDate, eventName, sponsors, coupons |
| drawing     | name, type, drawing{prize, prizeType}, liveTitle, qrSubTitle, teamTag, eventDate, eventName, sponsors, coupons |
| game        | name, type(target/rateit/bingo/seat), game{title, desc, target}, games[], distribution, result, delayed, teamTag, eventDate, eventName, sponsors, coupons |
| fanphoto    | name, target(number), teamTag, eventDate, eventName |
| fundraiser  | name, description, teamTag(string[]), sponsors, coupons |

## Critical Patterns

### 1. API Response Format

ALL server APIs must use the `apiResponse()` wrapper (auto-imported from `server/utils/apiResponse.ts`):

```typescript
// Success
return apiResponse({ result: data });
return apiResponse({ message: 'Updated successfully' });

// Error
return apiResponse({ status: false, message: 'Error description' });

// With pagination
return apiResponse({ result: items, meta: { items: total, page, pages } });
```

The response shape: `{ status: boolean, result?: T, message?: string, error?: T, meta?: {} }`

### 2. Auth in API Routes

```typescript
export default defineEventHandler(async event => {
  const USER_ID = Number(event.context.user.id);        // Always Number()
  const PROJECT_ID = Number(getRouterParam(event, 'id'));
  // event.context.user has: id, photo, permissions, etc.
});
```

### 3. Data Fetching - `await useLazyFetch`

SSR pre-renders on page refresh, lazy loads on client navigation:

```typescript
// Edit page - view API returns { result: project }
const { status, data } = await useLazyFetch<{ result: Record<string, unknown> }>(`/api/organizer/contest/${id}/view`);
const loading = computed(() => status.value === 'pending');

// Details page - index API returns project directly
const { status, data: project } = await useLazyFetch(`/api/organizer/contest/${PID}`);
```

### 4. Reusable Form Components (defineModel pattern)

Form components live in `app/components/org/[category]/form.vue`. They use `defineModel` for two-way binding, emit `submit` with FormData, and own the review modal + photo upload state:

```typescript
// Form component
const project = defineModel<ProjectType>('project', { required: true });
const props = defineProps<{ saving: boolean; submitLabel?: string; photoUrl?: string }>();
const emit = defineEmits<{ submit: [formData: FormData] }>();

const photo = reactive<{ base64: string; blob: Blob | null; old: string }>({
  base64: '', blob: null, old: props.photoUrl ?? '',
});

const onSubmit = () => {
  const formData = new FormData();
  formData.append('project', JSON.stringify(project.value));
  if (photo.blob) formData.append('photo', photo.blob, 'p0.webp');
  emit('submit', formData);  // Parent handles API call
};
```

```vue
<!-- Parent add page -->
<OrgContestForm v-model:project="project" :saving="saving" submit-label="Save" @submit="onSubmit" />

<!-- Parent edit page -->
<OrgContestForm v-else v-model:project="project" :saving="saving" :photo-url="photoUrl" submit-label="Update" @submit="onSubmit" />
```

### 5. Edit Page Pattern

Every edit page follows this exact structure:

```typescript
const { status, data } = await useLazyFetch<{ result: Record<string, unknown> }>(`/api/organizer/[category]/${id}/view`);
const loading = computed(() => status.value === 'pending');

watch(() => data.value, val => {
  const p = val?.result as Record<string, unknown> | undefined;
  if (!p) return;
  // Map API fields to reactive project object
  project.name = (p.name as string) ?? '';
  project.eventDate = p.eventDate ? (p.eventDate as string).substring(0, 10) : null;
  // ... map all fields
}, { immediate: true });

const onSubmit = async (formData: FormData) => {
  saving.value = true;
  try {
    const res = await $fetch(`/api/organizer/[category]/${id}`, { method: 'POST', body: formData });
    if (!res.status) throw new Error(res.message || 'Update failed');
    toast.add({ title: 'Updated successfully', color: 'success', icon: Icon.success });
    return navigateTo('/organizer/projects');
  } catch (err) {
    toast.add({ title: err.message, color: 'error', icon: Icon.error });
  } finally { saving.value = false; }
};
```

Template: show spinner while loading, form when ready.

### 6. View API Pattern (for edit pages)

```typescript
// server/routes/api/organizer/[category]/[id]/view.ts
export default defineEventHandler(async event => {
  const USER_ID = Number(event.context.user.id);
  const PROJECT_ID = Number(getRouterParam(event, 'id'));
  if (!PROJECT_ID) return apiResponse({ status: false, message: 'Project not found' });

  try {
    const project = await prisma.project.findUnique({
      where: { id: PROJECT_ID, userId: USER_ID },
      select: { /* all fields needed by edit form */ },
    });
    if (!project) return apiResponse({ status: false, message: 'Project not found' });
    return apiResponse({ result: project });
  } catch (err: unknown) {
    const message = err instanceof Error ? err.message : 'Error fetching project';
    return apiResponse({ status: false, message });
  }
});
```

### 7. Update API Pattern

```typescript
// server/routes/api/organizer/[category]/[id]/index.ts (POST handler)
const form = await readFormData(event);
const project = JSON.parse(form.get('project') as string);
const photo = form.get('photo') as unknown as File;

// Handle photo upload
if (photo) {
  const ret = await s3Upload(photo, USER_ID);
  if (ret.status && ret.url) project.photos.push(ret.url);
}

await prisma.project.update({
  where: { id: PROJECT_ID },
  data: {
    name: project.name,
    // ... map all fields
    sponsors: { set: [], connect: project.sponsors.map((s: {id: number}) => ({ id: Number(s.id) })) },
    coupons: { set: [], connect: project.coupons.map((c: {id: number}) => ({ id: Number(c.id) })) },
  },
});
return apiResponse({ message: 'Updated successfully' });
```

### 8. List Page Pattern

List pages fetch arrays, create a local reactive copy for UI manipulation, and handle delete:

```typescript
const { status, data } = await useLazyFetch<{ result: Project[] }>('/api/organizer/project/');
const isLoading = computed(() => status.value === 'pending' || !data.value?.result);

// Local reactive copy for immediate UI updates
const projects = ref<Project[]>([]);
watch(
  () => data.value?.result,
  (val) => { projects.value = val ? [...val] : []; },
  { immediate: true }
);

// Delete handler updates both local + fetch data
const deleted = (id: number) => {
  projects.value = projects.value.filter(p => p.id !== id);
  if (data.value && Array.isArray(data.value.result)) {
    data.value.result = data.value.result.filter((p: Project) => p.id !== id);
  }
};

const dltVal = reactive({ id: 0, open: false, table: 'project' });
```

### 9. List API Pattern (with pagination)

```typescript
// server/routes/api/organizer/[resource]/list.ts
export default defineEventHandler(async event => {
  const USER_ID = Number(event.context.user.id);
  const query = getQuery(event);
  const page = Number(query.page) || 1;
  const limit = Number(query.limit) || 50;
  const skip = (page - 1) * limit;

  try {
    const [items, totalCount] = await Promise.all([
      prisma.project.findMany({
        where: { userId: USER_ID },
        select: { /* needed fields */ },
        orderBy: { id: 'desc' },
        skip,
        take: limit,
      }),
      prisma.project.count({ where: { userId: USER_ID } }),
    ]);
    return apiResponse({ result: items, meta: { items: totalCount, page, pages: Math.ceil(totalCount / limit) } });
  } catch (err: unknown) {
    const message = err instanceof Error ? err.message : 'Error fetching data';
    return apiResponse({ status: false, message });
  }
});
```

### 10. Skeleton Loaders

Match the actual card layout structure:

```vue
<UCard v-for="i in 4" :key="i" variant="subtle">
  <div class="flex items-center gap-4">
    <USkeleton class="w-16 h-16 rounded-2xl shrink-0" />
    <div class="flex-1 space-y-3">
      <USkeleton class="h-5 w-48 rounded" />
      <USkeleton class="h-4 w-32 rounded" />
    </div>
  </div>
</UCard>
```

## API File Naming

- **Combined methods**: `index.ts` handles GET/POST/PUT/DELETE via `event.method` check
- **Single method**: `.get.ts`, `.post.ts`, `.put.ts`, `.delete.ts` suffixes
- **Convention**: Category index uses `index.post.ts` (create), `[id]/index.ts` (combined CRUD), `[id]/view.ts` (edit form data)

## Environment Variables

**Server-only** (in `nuxt.config.ts` runtimeConfig):
- `DATABASE_URL`, `DIRECT_URL` - PostgreSQL
- `STRIPE_SECRET_KEY` - Payments
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `AWS_BUCKET` - S3
- `SUPABASE_SERVICE_ROLE_KEY` - Admin auth
- `OPENAI_API_KEY` - AI features
- `JWT_SECRET` - Token signing

**Public** (runtimeConfig.public):
- `BASE_URL`, `SUPABASE_URL`, `SUPABASE_KEY`

## Strict Rules

1. **No Zod** - Do NOT use Zod for validation in server APIs. Use Prisma-generated types from `~~/prisma/generated/models` for type safety. Prisma handles validation at the database level. (Zod is OK only in AI files for response parsing)
2. **apiResponse()** - ALL server API returns must use this wrapper
3. **`Number(event.context.user.id)`** - Always convert user ID to number
4. **`await useLazyFetch`** - SSR + lazy client navigation (NOT `useFetch` or `$fetch` for page data)
5. **`defineModel`** for shared form components - NOT `props` + `emit('update:...')`
6. **Review modal stays open** during API call - button shows `:loading="saving"`, closes via `navigateTo` on success
7. **Sponsors/coupons** use `{ set: [], connect: [...] }` pattern to replace all relations
8. **eventDate** - stored as DateTime in DB, displayed as `substring(0, 10)` for date input, saved as `new Date()`
9. **Photos** - uploaded via FormData with `s3Upload()`, old photos deleted with `s3Delete()`
10. **Auto-imports** - `prisma`, `apiResponse`, `s3Upload`, `s3Delete`, `getRouterParam`, `readFormData`, `readBody`, `getQuery` are all auto-imported in server routes

## Key Composables (auto-imported)

| Composable | Usage |
|---|---|
| `useIcons()` | Icon name constants: `Icon.success`, `Icon.error`, `Icon.close`, `Icon.search` |
| `useToast()` | `toast.add({ title, color: 'success'/'error', icon })` |
| `imgSrc(url)` | Converts S3 key to full URL, returns placeholder if empty |
| `formatDate(date)` | Formats to "12 Jan 2025" |
| `contestOptions` | Select items for contest types |
| `gameOptions` | Select items for game types |
| `drawingOptions` | Select items for drawing types |
| `prizeOptions` | Select items for prize types |
| `distributionOptions` | Select items for distribution |
| `resultsOptions` | Select items for result delivery |

## Common Components

| Component | Props | Usage |
|---|---|---|
| `<PhotoUpload>` | `:photo="reactive({base64,blob,old})" label="Image"` | Photo upload with crop |
| `<OrgHeader>` | `title="Page Title"` | Dashboard page header |
| `<OrgNoData>` | `title="No items"` | Empty state |
| `<OrgDeleteModal>` | `v-model="dltVal" @delete="handler"` | Confirm delete |
| `<OrgSponsorSelect>` | `v-model:sponsors v-model:coupons` | Sponsor + coupon picker |
| `<OrgProjectsHeaderMenu>` | `:id :project :loading` | Status/edit/delete actions |
| `<UDashboardPanel>` | `#header #body` | Page wrapper |
| `<UCard>` | `variant="solid"/"subtle"` | Card container |
| `<USkeleton>` | `class="h-5 w-48 rounded"` | Loading placeholder |

## Commands

```bash
pnpm dev              # Dev server (port 3000)
pnpm build            # Production build
pnpm lint             # ESLint
pnpm typecheck        # TypeScript check
pnpm run PFormat      # Format Prisma schema
pnpm run PGenerate    # Generate Prisma client + types
```

## Quick Reference

| What | Where |
|---|---|
| API endpoint | `server/routes/api/organizer/[category]/[action].ts` |
| View API (edit) | `server/routes/api/organizer/[category]/[id]/view.ts` |
| Update API | `server/routes/api/organizer/[category]/[id]/index.ts` (POST) |
| Create API | `server/routes/api/organizer/[category]/index.post.ts` |
| Form component | `app/components/org/[category]/form.vue` |
| View component | `app/components/org/[category]/view.vue` |
| Add page | `app/pages/organizer/[category]/add.vue` |
| Edit page | `app/pages/organizer/[category]/[id]/edit.vue` |
| Details page | `app/pages/organizer/[category]/[id]/details.vue` |
| Manage page | `app/pages/organizer/[category]/[id]/manage.vue` |
| Prisma types | `~~/prisma/generated/models` |
| API response util | `server/utils/apiResponse.ts` |
| S3 actions | `server/utils/s3Actions.post.ts` |
| Options/data | `app/composables/data.ts` |
