diff --git a/services/mana-core-auth/docs/GIFT_CODES.md b/services/mana-core-auth/docs/GIFT_CODES.md new file mode 100644 index 000000000..8e461f9dc --- /dev/null +++ b/services/mana-core-auth/docs/GIFT_CODES.md @@ -0,0 +1,257 @@ +# Gift Code System + +User-generated gift codes for sharing credits across the Mana ecosystem. + +## Overview + +Users can create gift codes to share credits with others. The system supports various modes: + +| Type | Description | +|------|-------------| +| `simple` | Single-use, one recipient | +| `personalized` | Restricted to specific email/Matrix ID | +| `split` | Divided into portions (e.g., 100 credits / 5 = 20 each) | +| `first_come` | First N users get full amount | +| `riddle` | Requires correct answer to redeem | + +## API Endpoints + +Base URL: `/api/v1/gifts` + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/:code` | - | Get gift code info (public preview) | +| POST | `/` | JWT | Create new gift code | +| POST | `/:code/redeem` | JWT | Redeem a gift code | +| GET | `/me/created` | JWT | List codes you created | +| GET | `/me/received` | JWT | List gifts you received | +| DELETE | `/:id` | JWT | Cancel code & refund unclaimed | + +## Usage Examples + +### Create Gift Code + +```bash +curl -X POST "https://auth.mana.how/api/v1/gifts" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "credits": 100, + "message": "Happy Birthday!", + "type": "simple" + }' +``` + +**Response:** +```json +{ + "id": "uuid", + "code": "ABC123", + "url": "https://mana.how/g/ABC123", + "totalCredits": 100, + "creditsPerPortion": 100, + "totalPortions": 1, + "type": "simple", + "expiresAt": "2026-05-14T00:00:00Z" +} +``` + +### Create Split Gift (5 portions) + +```bash +curl -X POST "https://auth.mana.how/api/v1/gifts" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "credits": 100, + "type": "split", + "portions": 5, + "message": "Share this with friends!" + }' +``` + +Each recipient gets 20 credits (100 / 5). + +### Create Riddle Gift + +```bash +curl -X POST "https://auth.mana.how/api/v1/gifts" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "credits": 50, + "type": "riddle", + "riddleQuestion": "What is the capital of Germany?", + "riddleAnswer": "Berlin" + }' +``` + +### Get Gift Info (Public) + +```bash +curl "https://auth.mana.how/api/v1/gifts/ABC123" +``` + +**Response:** +```json +{ + "code": "ABC123", + "type": "simple", + "status": "active", + "creditsPerPortion": 100, + "totalPortions": 1, + "remainingPortions": 1, + "message": "Happy Birthday!", + "hasRiddle": false, + "isPersonalized": false, + "expiresAt": "2026-05-14T00:00:00Z", + "creatorName": "John Doe" +} +``` + +### Redeem Gift Code + +```bash +curl -X POST "https://auth.mana.how/api/v1/gifts/ABC123/redeem" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +For riddle gifts, include the answer: +```bash +curl -X POST "https://auth.mana.how/api/v1/gifts/ABC123/redeem" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"answer": "Berlin"}' +``` + +**Response:** +```json +{ + "success": true, + "creditsReceived": 100, + "newBalance": 250, + "message": "Happy Birthday!" +} +``` + +## Matrix Bot Commands + +### German +``` +!geschenk 50 # Simple gift +!geschenk 100 /5 # Split into 5 portions +!geschenk 50 ?="Berlin" # With riddle +!geschenk 50 "Viel Spass!" # With message +!einloesen ABC123 # Redeem code +!einloesen ABC123 Berlin # Redeem with riddle answer +!meine-geschenke # List your gifts +``` + +### English +``` +!gift 50 +!gift 100 /5 +!gift 50 ?="Berlin" +!redeem ABC123 +!my-gifts +``` + +## Database Schema + +### Tables + +**gifts.gift_codes** +- `id` - UUID primary key +- `code` - Unique 6-char code (e.g., "ABC123") +- `short_url` - Full URL (e.g., "mana.how/g/ABC123") +- `creator_id` - FK to auth.users +- `total_credits` - Reserved amount +- `credits_per_portion` - Credits per redemption +- `total_portions` - Number of portions +- `claimed_portions` - Portions already redeemed +- `type` - simple|personalized|split|first_come|riddle +- `status` - active|depleted|expired|cancelled|refunded +- `target_email` - For personalized gifts +- `target_matrix_id` - For personalized gifts +- `riddle_question` - Question text +- `riddle_answer_hash` - bcrypt hash of answer +- `message` - Optional message +- `expires_at` - Expiration timestamp +- `reservation_transaction_id` - FK to credits.transactions + +**gifts.gift_redemptions** +- `id` - UUID primary key +- `gift_code_id` - FK to gift_codes +- `redeemer_user_id` - FK to auth.users +- `status` - success|failed_wrong_answer|failed_wrong_user|... +- `credits_received` - Amount credited +- `portion_number` - Which portion was claimed +- `credit_transaction_id` - FK to credits.transactions +- `source_app_id` - 'matrix-bot', 'web', etc. + +### Transaction Types + +Credits schema includes gift-related transaction types: +- `gift_reserve` - Credits reserved when creating gift +- `gift_release` - Credits returned when cancelling gift +- `gift_receive` - Credits received when redeeming gift + +## Integration Points + +### Web Apps (SvelteKit) +```typescript +// Fetch gift info +const response = await fetch(`/api/v1/gifts/${code}`); +const giftInfo = await response.json(); + +// Redeem +const result = await fetch(`/api/v1/gifts/${code}/redeem`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ answer: riddleAnswer }) +}); +``` + +### Matrix Bots +```typescript +import { GiftService } from '@manacore/bot-services'; +import { handleGiftCommand } from '@manacore/matrix-bot-common'; + +// In bot command handler +if (isGiftCommand(command)) { + return handleGiftCommand(this, roomId, userId, command, args); +} +``` + +### Mobile Apps (Expo) +Same REST API, use fetch or axios with JWT token. + +## Security + +- Gift codes use 6-char alphanumeric codes (no ambiguous chars) +- Riddle answers are bcrypt hashed +- Row-level locking prevents race conditions +- Credits are reserved atomically when creating gifts +- Personalized gifts verify email or Matrix ID + +## Configuration + +Environment variables: +```env +# Base URL for short links +APP_BASE_URL=https://mana.how +``` + +Gift code rules (hardcoded): +```typescript +const GIFT_CODE_RULES = { + minCredits: 1, + maxCredits: 10000, + maxPortions: 100, + maxMessageLength: 500, + maxRiddleQuestionLength: 200, + defaultExpirationDays: 90, +}; +``` diff --git a/services/mana-core-auth/src/common/guards/jwt-auth.guard.ts b/services/mana-core-auth/src/common/guards/jwt-auth.guard.ts index ad7e458e8..466f5eef0 100644 --- a/services/mana-core-auth/src/common/guards/jwt-auth.guard.ts +++ b/services/mana-core-auth/src/common/guards/jwt-auth.guard.ts @@ -43,9 +43,10 @@ export class JwtAuthGuard implements CanActivate { } // IMPORTANT: Match Better Auth signing config exactly (better-auth.config.ts) - // Signing uses: issuer = BASE_URL, audience = JWT_AUDIENCE || 'manacore' - const baseUrl = this.configService.get('BASE_URL') || 'http://localhost:3001'; - const issuer = baseUrl; // Better Auth uses BASE_URL as issuer for OIDC compatibility + // Better Auth uses: issuer = BASE_URL || JWT_ISSUER || 'http://localhost:3001' + const baseUrl = this.configService.get('BASE_URL'); + const jwtIssuer = this.configService.get('jwt.issuer'); + const issuer = baseUrl || jwtIssuer || 'http://localhost:3001'; const audience = this.configService.get('jwt.audience') || 'manacore'; const { payload } = await jwtVerify(token, this.jwks, { diff --git a/services/mana-core-auth/src/gifts/gifts.controller.ts b/services/mana-core-auth/src/gifts/gifts.controller.ts index 45e32bd84..67d4d5f53 100644 --- a/services/mana-core-auth/src/gifts/gifts.controller.ts +++ b/services/mana-core-auth/src/gifts/gifts.controller.ts @@ -16,7 +16,7 @@ import { CreateGiftDto } from './dto/create-gift.dto'; import { RedeemGiftDto } from './dto/redeem-gift.dto'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; -@Controller('api/v1/gifts') +@Controller('gifts') export class GiftsController { constructor(private readonly giftCodeService: GiftCodeService) {} @@ -50,11 +50,7 @@ export class GiftsController { @Post(':code/redeem') @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) - async redeemGift( - @Request() req: any, - @Param('code') code: string, - @Body() dto: RedeemGiftDto - ) { + async redeemGift(@Request() req: any, @Param('code') code: string, @Body() dto: RedeemGiftDto) { const userId = req.user.sub; const userEmail = req.user.email; // Matrix ID would be passed in the request if coming from a Matrix bot