docs(01-foundation-client-dashboard): complete phase 1 planning with 5-plan structure
Create comprehensive phase plans for Foundation & Client Dashboard: - 01-01-PLAN.md: Walking Skeleton (Next.js 15 bootstrap + DB connection) - 01-02-PLAN.md: Database schema (11 tables, Drizzle ORM, drizzle-kit push) - 01-03-PLAN.md: Middleware token validation + ClientView type + data fetching - 01-04-PLAN.md: Client dashboard UI (header, timeline, progress, payments, docs, notes) - 01-05-PLAN.md: Seed script + DNS CNAME configuration Also create SKELETON.md documenting locked architectural decisions for all future phases: - Next.js 15 + Drizzle + postgres-js driver (Coolify Postgres) - Token as separate rotatable field (not PK) - ClientView enforcement (no quote_items exposed to client API) - Approved_at immutable audit trail - Two independent auth systems (client token + admin session) - Vercel deployment with custom domain Update ROADMAP.md to mark Phase 1 as planned (5 plans created) and ready for execution. All plans follow MVP vertical-slice structure with 2-3 tasks per plan. Walking Skeleton proves the entire stack works end-to-end. Requirements mapping: DASH-01 through DASH-04, DASH-07 through DASH-10 covered. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,567 @@
|
||||
---
|
||||
phase: "01-foundation-client-dashboard"
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on:
|
||||
- "01-01"
|
||||
- "01-02"
|
||||
- "01-03"
|
||||
- "01-04"
|
||||
files_modified:
|
||||
- scripts/seed.ts
|
||||
- .env.local
|
||||
autonomous: true
|
||||
requirements:
|
||||
- DASH-01
|
||||
- DASH-02
|
||||
- DASH-03
|
||||
- DASH-04
|
||||
- DASH-07
|
||||
- DASH-08
|
||||
- DASH-09
|
||||
- DASH-10
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Seed script exists and contains TypeScript seed logic"
|
||||
- "Script inserts one complete test client with all related data (phases, tasks, deliverables, payments, documents, notes)"
|
||||
- "Client token is generated via nanoid (21 chars, cryptographically secure)"
|
||||
- "Seed script prints shareable URL to console: http://localhost:3000/c/[token]"
|
||||
- "Script can be run via: npx tsx scripts/seed.ts"
|
||||
- "DNS CNAME is configured: welcomeclient.iamcavalli.net → vercel DNS"
|
||||
- "DNS propagation is verified (can be checked via `dig` or online tool)"
|
||||
artifacts:
|
||||
- path: "scripts/seed.ts"
|
||||
provides: "Seed script that inserts first real client with all data"
|
||||
min_lines: 100
|
||||
contains: "import.*nanoid"
|
||||
- path: ".env.local (updated)"
|
||||
provides: "Updated with VERCEL_URL or custom domain setting"
|
||||
contains: "DATABASE_URL"
|
||||
key_links:
|
||||
- from: "scripts/seed.ts"
|
||||
to: "src/db/schema"
|
||||
via: "drizzle db.insert()"
|
||||
pattern: "db.insert\\("
|
||||
- from: "nanoid token"
|
||||
to: "client URL"
|
||||
via: "http://localhost:3000/c/[token]"
|
||||
pattern: "nanoid"
|
||||
|
||||
---
|
||||
|
||||
<objective>
|
||||
**Seed Script + DNS Configuration:** Create a TypeScript seed script that populates the database with one complete test client (including phases, tasks, deliverables, payments, documents, and notes), generates a secret token via nanoid, and prints a shareable dashboard URL. Configure DNS CNAME for welcomeclient.iamcavalli.net to Vercel and verify propagation.
|
||||
|
||||
Purpose: Enable end-to-end testing with real data. One developer can run the seed script and immediately open a working client dashboard. DNS configuration allows the project to be accessed via the production domain.
|
||||
|
||||
Output: Executable seed script + verified DNS CNAME + shareable client link for testing Phase 1.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/research/ARCHITECTURE.md (Data Model section)
|
||||
@.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (D-13)
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create scripts/seed.ts to insert first real client with all data</name>
|
||||
<files>
|
||||
scripts/seed.ts
|
||||
</files>
|
||||
<read_first>
|
||||
src/db/schema.ts (all table definitions)
|
||||
src/db/index.ts (db client)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `scripts/seed.ts`:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Seed Script — Inserts first test client with complete project data
|
||||
* Run: npx tsx scripts/seed.ts
|
||||
*/
|
||||
|
||||
import { db } from '@/db';
|
||||
import {
|
||||
clients,
|
||||
phases,
|
||||
tasks,
|
||||
deliverables,
|
||||
payments,
|
||||
documents,
|
||||
notes,
|
||||
} from '@/db/schema';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
async function seed() {
|
||||
console.log('🌱 Seeding database...\n');
|
||||
|
||||
try {
|
||||
// 1. Create client
|
||||
const clientToken = nanoid();
|
||||
const [client] = await db
|
||||
.insert(clients)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
name: 'Test Client Inc.',
|
||||
brand_name: 'TestBrand',
|
||||
brief:
|
||||
'A comprehensive personal branding overhaul, positioning our company as a premium consultancy in the digital transformation space.',
|
||||
token: clientToken,
|
||||
accepted_total: '5000.00',
|
||||
created_at: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
console.log(
|
||||
'✓ Client created: ' + client.name + ' (ID: ' + client.id + ')'
|
||||
);
|
||||
|
||||
// 2. Create phases
|
||||
const [phase1, phase2, phase3] = await db
|
||||
.insert(phases)
|
||||
.values([
|
||||
{
|
||||
id: nanoid(),
|
||||
client_id: client.id,
|
||||
title: 'Discovery & Strategy',
|
||||
sort_order: 1,
|
||||
status: 'done',
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
client_id: client.id,
|
||||
title: 'Design & Messaging',
|
||||
sort_order: 2,
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
client_id: client.id,
|
||||
title: 'Implementation & Launch',
|
||||
sort_order: 3,
|
||||
status: 'upcoming',
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
|
||||
console.log('✓ Phases created (3 total)');
|
||||
|
||||
// 3. Create tasks
|
||||
const [task1, task2, task3, task4, task5, task6] = await db
|
||||
.insert(tasks)
|
||||
.values([
|
||||
{
|
||||
id: nanoid(),
|
||||
phase_id: phase1.id,
|
||||
title: 'Stakeholder interviews',
|
||||
description: 'In-depth conversations with leadership team',
|
||||
sort_order: 1,
|
||||
status: 'done',
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
phase_id: phase1.id,
|
||||
title: 'Competitive analysis',
|
||||
description: 'Research top 10 competitors in the space',
|
||||
sort_order: 2,
|
||||
status: 'done',
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
phase_id: phase2.id,
|
||||
title: 'Brand positioning document',
|
||||
description:
|
||||
'Write and refine the core positioning statement',
|
||||
sort_order: 1,
|
||||
status: 'in_progress',
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
phase_id: phase2.id,
|
||||
title: 'Visual identity design',
|
||||
description: 'Logo, color palette, typography',
|
||||
sort_order: 2,
|
||||
status: 'in_progress',
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
phase_id: phase3.id,
|
||||
title: 'Website build & launch',
|
||||
description: 'Design and develop new company website',
|
||||
sort_order: 1,
|
||||
status: 'todo',
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
phase_id: phase3.id,
|
||||
title: 'Social media rollout',
|
||||
description: 'Launch branded social media accounts',
|
||||
sort_order: 2,
|
||||
status: 'todo',
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
|
||||
console.log('✓ Tasks created (6 total)');
|
||||
|
||||
// 4. Create deliverables
|
||||
await db
|
||||
.insert(deliverables)
|
||||
.values([
|
||||
{
|
||||
id: nanoid(),
|
||||
task_id: task1.id,
|
||||
title: 'Interview notes & synthesis',
|
||||
url: 'https://docs.google.com/document/d/1example',
|
||||
status: 'approved',
|
||||
approved_at: new Date('2026-04-15'),
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
task_id: task2.id,
|
||||
title: 'Competitive landscape report',
|
||||
url: 'https://docs.google.com/presentation/d/1example',
|
||||
status: 'approved',
|
||||
approved_at: new Date('2026-04-20'),
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
task_id: task3.id,
|
||||
title: 'Brand positioning document (draft)',
|
||||
url: 'https://docs.google.com/document/d/2example',
|
||||
status: 'submitted',
|
||||
approved_at: null,
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
task_id: task4.id,
|
||||
title: 'Logo concepts (3 variations)',
|
||||
url: 'https://www.figma.com/file/example',
|
||||
status: 'pending',
|
||||
approved_at: null,
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
|
||||
console.log('✓ Deliverables created (4 total)');
|
||||
|
||||
// 5. Create payments
|
||||
await db
|
||||
.insert(payments)
|
||||
.values([
|
||||
{
|
||||
id: nanoid(),
|
||||
client_id: client.id,
|
||||
label: 'Acconto 50%',
|
||||
amount: '2500.00',
|
||||
status: 'saldato',
|
||||
paid_at: new Date('2026-04-01'),
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
client_id: client.id,
|
||||
label: 'Saldo 50%',
|
||||
amount: '2500.00',
|
||||
status: 'inviata',
|
||||
paid_at: null,
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
|
||||
console.log('✓ Payments created (2 total)');
|
||||
|
||||
// 6. Create documents
|
||||
await db
|
||||
.insert(documents)
|
||||
.values([
|
||||
{
|
||||
id: nanoid(),
|
||||
client_id: client.id,
|
||||
label: 'Brand Guidelines PDF',
|
||||
url: 'https://example.com/brand-guidelines.pdf',
|
||||
created_at: new Date(),
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
client_id: client.id,
|
||||
label: 'Design Mockups Figma',
|
||||
url: 'https://www.figma.com/file/example',
|
||||
created_at: new Date(),
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
|
||||
console.log('✓ Documents created (2 total)');
|
||||
|
||||
// 7. Create notes
|
||||
await db
|
||||
.insert(notes)
|
||||
.values([
|
||||
{
|
||||
id: nanoid(),
|
||||
client_id: client.id,
|
||||
body: 'Initial strategy session completed. Key insight: positioning needs to emphasize tech expertise and creative thinking balance.',
|
||||
created_at: new Date('2026-04-10'),
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
client_id: client.id,
|
||||
body: 'Phase 1 approved. Moving forward with design phase. Stakeholders excited about direction.',
|
||||
created_at: new Date('2026-04-22'),
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
|
||||
console.log('✓ Notes created (2 total)');
|
||||
|
||||
// Print shareable URL
|
||||
console.log('\n✨ Seed complete!\n');
|
||||
console.log('📎 Shareable client link:');
|
||||
console.log(
|
||||
` http://localhost:3000/c/${clientToken}\n`
|
||||
);
|
||||
console.log(
|
||||
'This link is unique and secret. Send it to the client via Slack or email.\n'
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('❌ Seed failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
seed();
|
||||
```
|
||||
|
||||
Key points:
|
||||
- Uses nanoid for token generation (21 chars, cryptographically secure)
|
||||
- Inserts complete hierarchical data: 1 client → 3 phases → 6 tasks → 4 deliverables + 2 payments + 2 documents + 2 notes
|
||||
- Mix of statuses: phase 1 done, phase 2 active, phase 3 upcoming; tasks have various completion states
|
||||
- Deliverables show different statuses: approved (with timestamp), submitted, pending
|
||||
- Payments: one paid, one sent but unpaid
|
||||
- Notes: 2 decision log entries
|
||||
- Prints shareable URL to console
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f scripts/seed.ts && echo "Seed script exists"</automated>
|
||||
<automated>grep -q "import.*nanoid" scripts/seed.ts && echo "nanoid imported"</automated>
|
||||
<automated>grep -q "db.insert" scripts/seed.ts && echo "Insert statements present"</automated>
|
||||
<automated>grep -q "clientToken" scripts/seed.ts && echo "Token generation present"</automated>
|
||||
<automated>grep -q "http://localhost:3000/c/" scripts/seed.ts && echo "URL printed"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `scripts/seed.ts` exists as TypeScript file
|
||||
- Script imports nanoid and db client
|
||||
- Creates one complete client with all related data (phases, tasks, deliverables, payments, documents, notes)
|
||||
- Prints shareable URL to console
|
||||
- Can be executed via `npx tsx scripts/seed.ts` without errors
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Test seed script execution and verify data is inserted into database</name>
|
||||
<files>
|
||||
None (execution only)
|
||||
</files>
|
||||
<read_first>
|
||||
scripts/seed.ts
|
||||
.env.local
|
||||
</read_first>
|
||||
<action>
|
||||
Run the seed script:
|
||||
```
|
||||
npx tsx scripts/seed.ts
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
🌱 Seeding database...
|
||||
|
||||
✓ Client created: Test Client Inc. (ID: xxx...)
|
||||
✓ Phases created (3 total)
|
||||
✓ Tasks created (6 total)
|
||||
✓ Deliverables created (4 total)
|
||||
✓ Payments created (2 total)
|
||||
✓ Documents created (2 total)
|
||||
✓ Notes created (2 total)
|
||||
|
||||
✨ Seed complete!
|
||||
|
||||
📎 Shareable client link:
|
||||
http://localhost:3000/c/[token]
|
||||
|
||||
This link is unique and secret. Send it to the client via Slack or email.
|
||||
```
|
||||
|
||||
If the script fails:
|
||||
- Verify DATABASE_URL is set and correct
|
||||
- Verify Postgres on Coolify is accessible
|
||||
- Check that schema exists (run `npx drizzle-kit introspect` to confirm)
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsx scripts/seed.ts 2>&1 | grep -q "Seed complete" && echo "Seed script succeeded" || echo "Seed script failed"</automated>
|
||||
<automated>npx tsx scripts/seed.ts 2>&1 | grep -oE "http://localhost:3000/c/[a-zA-Z0-9_-]+" | head -1 > /tmp/client_url.txt && test -s /tmp/client_url.txt && echo "Client URL generated" || echo "Client URL not found"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Seed script executes without errors
|
||||
- Output shows all entity types created (client, phases, tasks, deliverables, payments, documents, notes)
|
||||
- Shareable URL is printed to console
|
||||
- Data is inserted into Postgres on Coolify
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Test end-to-end: Open seeded client link in browser and verify dashboard renders</name>
|
||||
<files>
|
||||
None (verification only)
|
||||
</files>
|
||||
<read_first>
|
||||
None
|
||||
</read_first>
|
||||
<action>
|
||||
Start dev server:
|
||||
```
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open the seeded client link in browser:
|
||||
- Copy the URL from seed script output (e.g., http://localhost:3000/c/xyz123)
|
||||
- Visit in browser
|
||||
- Verify dashboard renders with:
|
||||
- ✓ Client brand name displayed prominently
|
||||
- ✓ iamcavalli logo in corner
|
||||
- ✓ Global progress bar showing % completion
|
||||
- ✓ All 3 phases visible with status badges (done/active/upcoming)
|
||||
- ✓ Each phase shows progress bar and task count
|
||||
- ✓ Tasks nested under phases with status icons
|
||||
- ✓ Deliverables shown under tasks (with Approved badge if applicable)
|
||||
- ✓ Payment section shows accepted_total (€5000.00) and 2 payment rows
|
||||
- ✓ Payment amounts are NOT visible (only status: saldato, inviata)
|
||||
- ✓ Document section shows clickable links
|
||||
- ✓ Notes section shows decision log entries
|
||||
|
||||
Test edge cases:
|
||||
- Invalid token (http://localhost:3000/c/invalid) → should return 404
|
||||
- Page refresh → data should persist (no client-side state loss)
|
||||
- Mobile view (use DevTools mobile emulator) → layout should be responsive
|
||||
</action>
|
||||
<verify>
|
||||
<automated>curl -s http://localhost:3000/c/invalid | grep -q "404\|not found" && echo "Invalid token returns 404" || echo "404 check inconclusive"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Seeded client link opens without errors
|
||||
- Dashboard renders with client data
|
||||
- All sections visible: header, progress, phases, tasks, deliverables, payments, documents, notes
|
||||
- Invalid token returns 404
|
||||
- Layout is responsive on mobile
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Configure DNS CNAME for welcomeclient.iamcavalli.net → Vercel DNS</name>
|
||||
<files>
|
||||
None (external DNS configuration)
|
||||
</files>
|
||||
<read_first>
|
||||
.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (D-03)
|
||||
</read_first>
|
||||
<action>
|
||||
**DNS Configuration Steps:**
|
||||
|
||||
1. Log into your domain registrar (where iamcavalli.net is registered)
|
||||
2. Navigate to DNS settings for iamcavalli.net
|
||||
3. Create a new CNAME record:
|
||||
- **Name:** welcomeclient
|
||||
- **Type:** CNAME
|
||||
- **Value:** cname.vercel-dns.com
|
||||
- **TTL:** 3600 (or default)
|
||||
|
||||
4. Save the record
|
||||
|
||||
5. Verify propagation (may take 15 minutes to 2 hours):
|
||||
```
|
||||
dig welcomeclient.iamcavalli.net
|
||||
```
|
||||
|
||||
You should see:
|
||||
```
|
||||
welcomeclient.iamcavalli.net. 3600 IN CNAME cname.vercel-dns.com.
|
||||
```
|
||||
|
||||
Or use an online tool: https://mxtoolbox.com/cname.aspx
|
||||
|
||||
**Vercel Configuration:**
|
||||
|
||||
1. Go to Vercel dashboard → Project Settings → Domains
|
||||
2. Add domain: `welcomeclient.iamcavalli.net`
|
||||
3. Vercel will show the CNAME record to configure (should match above)
|
||||
4. Click "Add" and wait for verification (usually immediate after DNS propagates)
|
||||
|
||||
**After DNS is live:**
|
||||
- You can access the dashboard via https://welcomeclient.iamcavalli.net/c/[token]
|
||||
- DNS is bidirectional: localhost:3000 still works for dev
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dig welcomeclient.iamcavalli.net +short 2>/dev/null | grep -q "vercel-dns.com" && echo "DNS CNAME configured" || echo "DNS CNAME not yet live"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- CNAME record is created at registrar: welcomeclient → cname.vercel-dns.com
|
||||
- Vercel project has the domain added and verified
|
||||
- `dig` shows the CNAME record pointing to Vercel DNS
|
||||
- Domain is accessible via browser (may take time to propagate)
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Client browser → Secret link | Token is in URL; HTTPS encrypts transit; never log token in server logs |
|
||||
| Token generation | nanoid is cryptographically secure (126 bits entropy); non-enumerable |
|
||||
| DNS configuration | CNAME points to Vercel; Vercel controls SSL/TLS for domain |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-05-001 | Information Disclosure | Token in seed output | mitigate | URL is printed to console; developer must not commit or share the seed output; regenerate token in Phase 2 if compromised |
|
||||
| T-05-002 | Information Disclosure | HTTPS for domain | mitigate | Vercel automatically provisions SSL/TLS for custom domain; all traffic to welcomeclient.iamcavalli.net is encrypted |
|
||||
| T-05-003 | Denial of Service | Seed script re-run | accept | Running seed script multiple times creates duplicate clients (same test data); acceptable for dev; Phase 2 adds admin UI to manage clients |
|
||||
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After plan execution:
|
||||
1. Run `npx tsx scripts/seed.ts` → output shows "Seed complete!"
|
||||
2. Copy the printed URL and visit in browser
|
||||
3. Verify dashboard renders with seeded data
|
||||
4. Test invalid token → 404
|
||||
5. Verify DNS CNAME is live: `dig welcomeclient.iamcavalli.net`
|
||||
6. (Optional) Visit https://welcomeclient.iamcavalli.net/c/[token] once DNS propagates
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Seed script exists and inserts complete test data
|
||||
- One client with 3 phases, 6 tasks, 4 deliverables, 2 payments, 2 documents, 2 notes
|
||||
- Dashboard renders with seeded data via shareable link
|
||||
- Invalid tokens return 404
|
||||
- DNS CNAME is configured and verified
|
||||
- Phase 1 is complete and ready for production (Phase 2 will add auth and CRUD)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-foundation-client-dashboard/01-05-SUMMARY.md`
|
||||
|
||||
Also update `.planning/ROADMAP.md` to mark Phase 1 complete and set up Phase 2 planning.
|
||||
</output>
|
||||
Reference in New Issue
Block a user