From c121c1092f27705cbffae862bcf50a8dc9bcd9be Mon Sep 17 00:00:00 2001 From: qzl Date: Tue, 7 Apr 2026 18:43:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(invite):=20=E6=B7=BB=E5=8A=A0=E9=82=80?= =?UTF-8?q?=E8=AF=B7=E7=A0=81=E5=8A=9F=E8=83=BD=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/screens/invite_screen.dart | 521 ++++++++++++++++++ .../20260407_0002_add_invite_codes.py | 329 +++++++++++ backend/src/models/invite_code.py | 71 +++ backend/src/schemas/domain/invite_code.py | 42 +- 4 files changed, 962 insertions(+), 1 deletion(-) create mode 100644 apps/lib/features/settings/presentation/screens/invite_screen.dart create mode 100644 backend/alembic/versions/20260407_0002_add_invite_codes.py create mode 100644 backend/src/models/invite_code.py diff --git a/apps/lib/features/settings/presentation/screens/invite_screen.dart b/apps/lib/features/settings/presentation/screens/invite_screen.dart new file mode 100644 index 0000000..6c16134 --- /dev/null +++ b/apps/lib/features/settings/presentation/screens/invite_screen.dart @@ -0,0 +1,521 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../l10n/app_localizations.dart'; +import '../../../../shared/theme/app_color_palette.dart'; +import '../../../../shared/theme/design_tokens.dart'; +import '../../../../shared/widgets/toast/toast.dart'; +import '../../../../shared/widgets/toast/toast_type.dart'; + +class InviteScreen extends StatefulWidget { + const InviteScreen({super.key}); + + @override + State createState() => _InviteScreenState(); +} + +class _InviteScreenState extends State { + final _bindCodeController = TextEditingController(); + final _formKey = GlobalKey(); + + bool _isBinding = false; + bool _isGenerating = false; + + // Mock data - will be replaced with API calls + final String _myInviteCode = 'ABC123'; + final int _invitedCount = 3; + final bool _hasInviter = false; + + @override + void dispose() { + _bindCodeController.dispose(); + super.dispose(); + } + + bool get _hasMyInviteCode => _myInviteCode.isNotEmpty; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colors = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: colors.surfaceContainerLow, + appBar: AppBar( + title: Text(l10n.settingsInviteTitle), + centerTitle: true, + backgroundColor: colors.surfaceContainerLow, + surfaceTintColor: colors.surfaceContainerLow, + ), + body: ListView( + padding: const EdgeInsets.all(AppSpacing.lg), + children: [ + if (_hasMyInviteCode) ...[ + _InviteCodeCard(inviteCode: _myInviteCode, onCopy: _copyInviteCode), + const SizedBox(height: AppSpacing.lg), + _InviteStatsCard(count: _invitedCount), + const SizedBox(height: AppSpacing.xl), + _BindCodeSection( + controller: _bindCodeController, + formKey: _formKey, + isBinding: _isBinding, + onBind: _bindInviteCode, + ), + ] else ...[ + _EmptyStateCard( + controller: _bindCodeController, + formKey: _formKey, + isBinding: _isBinding, + isGenerating: _isGenerating, + hasInviter: _hasInviter, + onBind: _bindInviteCode, + onGenerate: _generateInviteCode, + ), + ], + ], + ), + ); + } + + void _copyInviteCode() { + final l10n = AppLocalizations.of(context)!; + Clipboard.setData(ClipboardData(text: _myInviteCode)); + Toast.show( + context, + l10n.settingsInviteCopySuccess, + type: ToastType.success, + ); + } + + Future _bindInviteCode() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isBinding = true); + + // Simulate API call + await Future.delayed(const Duration(seconds: 1)); + + if (!mounted) return; + + final l10n = AppLocalizations.of(context)!; + Toast.show( + context, + l10n.settingsInviteBindSuccess, + type: ToastType.success, + ); + + setState(() => _isBinding = false); + } + + Future _generateInviteCode() async { + setState(() => _isGenerating = true); + + // Simulate API call + await Future.delayed(const Duration(seconds: 1)); + + if (!mounted) return; + + final l10n = AppLocalizations.of(context)!; + Toast.show( + context, + l10n.settingsInviteGenerateSuccess, + type: ToastType.success, + ); + + setState(() => _isGenerating = false); + } +} + +class _InviteCodeCard extends StatelessWidget { + const _InviteCodeCard({required this.inviteCode, required this.onCopy}); + + final String inviteCode; + final VoidCallback onCopy; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colors = Theme.of(context).colorScheme; + final palette = Theme.of(context).extension()!; + + return Stack( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.xl), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.xl), + gradient: LinearGradient( + colors: [colors.primary, palette.accentPurple], + ), + ), + child: Column( + children: [ + Icon( + Icons.card_giftcard_rounded, + color: colors.onPrimary, + size: 40, + ), + const SizedBox(height: AppSpacing.md), + Text( + l10n.settingsInviteMyCode, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onPrimary.withValues(alpha: 0.88), + ), + ), + const SizedBox(height: AppSpacing.md), + Text( + inviteCode, + style: Theme.of(context).textTheme.headlineLarge?.copyWith( + color: colors.onPrimary, + fontWeight: FontWeight.w700, + letterSpacing: 6, + ), + ), + ], + ), + ), + Positioned( + top: AppSpacing.sm, + right: AppSpacing.sm, + child: Material( + color: colors.onPrimary.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(AppRadius.full), + child: InkWell( + onTap: onCopy, + borderRadius: BorderRadius.circular(AppRadius.full), + child: Container( + padding: const EdgeInsets.all(AppSpacing.sm), + child: Icon( + Icons.copy_rounded, + color: colors.onPrimary, + size: 20, + ), + ), + ), + ), + ), + ], + ); + } +} + +class _InviteStatsCard extends StatelessWidget { + const _InviteStatsCard({required this.count}); + + final int count; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colors = Theme.of(context).colorScheme; + + return Card( + margin: EdgeInsets.zero, + elevation: 0, + color: colors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.xl), + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Icon(Icons.people_outline_rounded, color: colors.primary), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Text( + l10n.settingsInviteStats(count), + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + ), + ); + } +} + +class _BindCodeSection extends StatelessWidget { + const _BindCodeSection({ + required this.controller, + required this.formKey, + required this.isBinding, + required this.onBind, + }); + + final TextEditingController controller; + final GlobalKey formKey; + final bool isBinding; + final Future Function() onBind; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colors = Theme.of(context).colorScheme; + + return Card( + margin: EdgeInsets.zero, + elevation: 0, + color: colors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.xl), + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.link_rounded, color: colors.primary, size: 20), + const SizedBox(width: AppSpacing.sm), + Text( + l10n.settingsInviteBindCode, + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: AppSpacing.md), + TextFormField( + controller: controller, + textCapitalization: TextCapitalization.characters, + decoration: InputDecoration( + hintText: l10n.settingsInviteBindPlaceholder, + filled: true, + fillColor: colors.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[A-Za-z0-9]')), + LengthLimitingTextInputFormatter(6), + ], + validator: (value) { + if (value == null || value.isEmpty) { + return null; // Optional field + } + if (value.length != 6) { + return l10n.settingsInviteInvalidCode; + } + return null; + }, + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: isBinding ? null : onBind, + style: FilledButton.styleFrom( + elevation: 0, + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.md, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.full), + ), + ), + child: isBinding + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text(l10n.settingsInviteBindButton), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _EmptyStateCard extends StatelessWidget { + const _EmptyStateCard({ + required this.controller, + required this.formKey, + required this.isBinding, + required this.isGenerating, + required this.hasInviter, + required this.onBind, + required this.onGenerate, + }); + + final TextEditingController controller; + final GlobalKey formKey; + final bool isBinding; + final bool isGenerating; + final bool hasInviter; + final Future Function() onBind; + final Future Function() onGenerate; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colors = Theme.of(context).colorScheme; + final palette = Theme.of(context).extension()!; + + return Column( + children: [ + Container( + padding: const EdgeInsets.all(AppSpacing.xxl), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(AppRadius.xl), + ), + child: Column( + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + colors.primary.withValues(alpha: 0.1), + palette.accentPurple.withValues(alpha: 0.1), + ], + ), + borderRadius: BorderRadius.circular(AppRadius.xl), + ), + child: Icon( + Icons.celebration_rounded, + color: colors.primary, + size: 40, + ), + ), + const SizedBox(height: AppSpacing.lg), + Text( + l10n.settingsInviteEmptyTitle, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.sm), + Text( + l10n.settingsInviteEmptyDescription, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + if (!hasInviter) ...[ + Card( + margin: EdgeInsets.zero, + elevation: 0, + color: colors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.xl), + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.settingsInviteInputLabel, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.sm), + TextFormField( + controller: controller, + textCapitalization: TextCapitalization.characters, + decoration: InputDecoration( + hintText: l10n.settingsInviteInputHint, + filled: true, + fillColor: colors.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + ), + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'[A-Za-z0-9]'), + ), + LengthLimitingTextInputFormatter(6), + ], + validator: (value) { + if (value == null || value.isEmpty) { + return null; + } + if (value.length != 6) { + return l10n.settingsInviteInvalidCode; + } + return null; + }, + ), + ], + ), + ), + ), + ), + const SizedBox(height: AppSpacing.md), + ], + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: isGenerating ? null : onGenerate, + style: FilledButton.styleFrom( + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.full), + ), + ), + child: isGenerating + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.add_rounded, size: 20), + const SizedBox(width: AppSpacing.sm), + Text(l10n.settingsInviteGenerateButton), + ], + ), + ), + ), + ], + ); + } +} diff --git a/backend/alembic/versions/20260407_0002_add_invite_codes.py b/backend/alembic/versions/20260407_0002_add_invite_codes.py new file mode 100644 index 0000000..97abd41 --- /dev/null +++ b/backend/alembic/versions/20260407_0002_add_invite_codes.py @@ -0,0 +1,329 @@ +"""add invite_codes table and profiles referred_by + +Revision ID: 20260407_0002 +Revises: 20260407_0001 +Create Date: 2026-04-07 00:00:00 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "20260407_0002" +down_revision: Union[str, Sequence[str], None] = "20260407_0001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE invite_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(6) NOT NULL UNIQUE CHECK (code ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$'), + owner_id UUID REFERENCES profiles(id) ON DELETE SET NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled', 'expired')), + used_count INTEGER NOT NULL DEFAULT 0 CHECK (used_count >= 0), + max_uses INTEGER CHECK (max_uses IS NULL OR max_uses >= 1), + expires_at TIMESTAMPTZ NULL, + reward_config JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """ + ) + op.execute("CREATE INDEX ix_invite_codes_owner_id ON invite_codes(owner_id)") + op.execute( + "CREATE INDEX ix_invite_codes_code ON invite_codes(code) WHERE status = 'active'" + ) + + op.execute("ALTER TABLE invite_codes ENABLE ROW LEVEL SECURITY") + op.execute("DROP POLICY IF EXISTS invite_codes_all_denied ON invite_codes") + op.execute( + "CREATE POLICY invite_codes_all_denied ON invite_codes FOR ALL USING (false)" + ) + + op.execute( + """ + ALTER TABLE profiles ADD COLUMN referred_by UUID REFERENCES profiles(id) ON DELETE SET NULL + """ + ) + op.execute("CREATE INDEX ix_profiles_referred_by ON profiles(referred_by)") + + op.execute( + """ + CREATE OR REPLACE FUNCTION public.generate_invite_code() + RETURNS TEXT + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = '' + AS $$ + DECLARE + chars TEXT := 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; + result TEXT := ''; + i INT; + BEGIN + FOR i IN 1..6 LOOP + result := result || substr(chars, floor(random() * length(chars) + 1)::int, 1); + END LOOP; + RETURN result; + END; + $$; + """ + ) + + op.execute( + """ + CREATE OR REPLACE FUNCTION public.initialize_profile_and_invite_code_on_signup() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = public + AS $$ + DECLARE + v_username text; + v_ledger_id uuid; + v_event_id text; + v_invite_code text; + v_referrer_id uuid; + v_attempts int := 0; + invite_code_value text; + BEGIN + v_username := 'user_' || substring(md5(new.id::text || clock_timestamp()::text || random()::text) from 1 for 6); + v_ledger_id := md5(new.id::text || 'ledger' || clock_timestamp()::text || random()::text)::uuid; + v_event_id := 'register:' || new.id::text; + + INSERT INTO public.profiles (id, username, avatar_url, bio, settings) + VALUES ( + new.id, + v_username, + null, + null, + jsonb_build_object( + 'version', 1, + 'preferences', jsonb_build_object( + 'interface_language', 'zh-CN', + 'ai_language', 'zh-CN', + 'timezone', 'Asia/Shanghai', + 'country', 'CN' + ), + 'privacy', jsonb_build_object('profile_visibility', 'public'), + 'notification', jsonb_build_object( + 'allow_notifications', true, + 'allow_vibration', true + ) + ) + ) + ON CONFLICT (id) DO NOTHING; + + INSERT INTO public.user_points ( + user_id, + balance, + frozen_balance, + lifetime_earned, + lifetime_spent, + version + ) + VALUES (new.id, 100, 0, 100, 0, 0) + ON CONFLICT (user_id) DO NOTHING; + + INSERT INTO public.points_ledger ( + id, + user_id, + direction, + amount, + balance_after, + change_type, + biz_type, + biz_id, + event_id, + operator_id, + metadata + ) + VALUES ( + v_ledger_id, + new.id, + 1, + 100, + 100, + 'register', + null, + null, + v_event_id, + null, + jsonb_build_object( + 'schema_version', 1, + 'reason_code', 'REGISTER_WELCOME', + 'operator_type', 'system', + 'run_id', v_event_id, + 'request_id', null, + 'ext', jsonb_build_object('source', 'auth_signup') + ) + ) + ON CONFLICT (user_id, event_id) DO NOTHING; + + LOOP + BEGIN + v_invite_code := public.generate_invite_code(); + INSERT INTO public.invite_codes (code, owner_id, status, used_count, max_uses, expires_at, reward_config) + VALUES ( + v_invite_code, + new.id, + 'active', + 0, + NULL, + NULL, + '{}'::jsonb + ); + EXIT; + EXCEPTION WHEN unique_violation THEN + v_attempts := v_attempts + 1; + IF v_attempts >= 100 THEN + RAISE EXCEPTION 'Failed to generate unique invite code after 100 attempts'; + END IF; + END; + END LOOP; + + invite_code_value := new.raw_user_meta_data ->> 'invite_code'; + IF invite_code_value IS NOT NULL AND length(invite_code_value) = 6 THEN + invite_code_value := upper(invite_code_value); + IF invite_code_value ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$' THEN + UPDATE public.invite_codes + SET used_count = used_count + 1 + WHERE code = invite_code_value + AND status = 'active' + AND (max_uses IS NULL OR used_count < max_uses) + AND (expires_at IS NULL OR expires_at > NOW()) + RETURNING owner_id INTO v_referrer_id; + + IF v_referrer_id IS NOT NULL THEN + UPDATE public.profiles + SET referred_by = v_referrer_id + WHERE id = new.id; + END IF; + END IF; + END IF; + + RETURN NEW; + END; + $$; + """ + ) + + op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users") + op.execute( + """ + CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW + EXECUTE FUNCTION public.initialize_profile_and_invite_code_on_signup() + """ + ) + + +def downgrade() -> None: + op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users") + + op.execute( + """ + CREATE OR REPLACE FUNCTION public.initialize_profile_and_points_on_signup() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = public + AS $$ + DECLARE + v_username text; + v_ledger_id uuid; + v_event_id text; + BEGIN + v_username := 'user_' || substring(md5(new.id::text || clock_timestamp()::text || random()::text) from 1 for 6); + v_ledger_id := md5(new.id::text || 'ledger' || clock_timestamp()::text || random()::text)::uuid; + v_event_id := 'register:' || new.id::text; + + INSERT INTO public.profiles (id, username, avatar_url, bio, settings) + VALUES ( + new.id, + v_username, + null, + null, + jsonb_build_object( + 'version', 1, + 'preferences', jsonb_build_object( + 'interface_language', 'zh-CN', + 'ai_language', 'zh-CN', + 'timezone', 'Asia/Shanghai', + 'country', 'CN' + ), + 'privacy', jsonb_build_object('profile_visibility', 'public'), + 'notification', jsonb_build_object('push_enabled', true) + ) + ) + ON CONFLICT (id) DO NOTHING; + + INSERT INTO public.user_points ( + user_id, + balance, + frozen_balance, + lifetime_earned, + lifetime_spent, + version + ) + VALUES (new.id, 100, 0, 100, 0, 0) + ON CONFLICT (user_id) DO NOTHING; + + INSERT INTO public.points_ledger ( + id, + user_id, + direction, + amount, + balance_after, + change_type, + biz_type, + biz_id, + event_id, + operator_id, + metadata + ) + VALUES ( + v_ledger_id, + new.id, + 1, + 100, + 100, + 'register', + null, + null, + v_event_id, + null, + jsonb_build_object( + 'schema_version', 1, + 'reason_code', 'REGISTER_WELCOME', + 'operator_type', 'system', + 'run_id', v_event_id, + 'request_id', null, + 'ext', jsonb_build_object('source', 'auth_signup') + ) + ) + ON CONFLICT (user_id, event_id) DO NOTHING; + + RETURN NEW; + END; + $$; + """ + ) + + op.execute( + "CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.initialize_profile_and_points_on_signup()" + ) + + op.execute("DROP FUNCTION IF EXISTS public.generate_invite_code()") + + op.execute("DROP INDEX IF EXISTS ix_profiles_referred_by") + op.execute("ALTER TABLE profiles DROP COLUMN IF EXISTS referred_by") + + op.execute("DROP POLICY IF EXISTS invite_codes_all_denied ON invite_codes") + op.execute("ALTER TABLE invite_codes DISABLE ROW LEVEL SECURITY") + op.execute("DROP INDEX IF EXISTS ix_invite_codes_code") + op.execute("DROP INDEX IF EXISTS ix_invite_codes_owner_id") + op.execute("DROP TABLE IF EXISTS invite_codes") diff --git a/backend/src/models/invite_code.py b/backend/src/models/invite_code.py new file mode 100644 index 0000000..33ebde3 --- /dev/null +++ b/backend/src/models/invite_code.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, TimestampMixin +from core.db.types import json_jsonb +from schemas.enums import InviteCodeStatus + +__all__ = ["InviteCode", "InviteCodeStatus"] + + +class InviteCode(TimestampMixin, Base): + __tablename__: str = "invite_codes" + __table_args__ = ( + CheckConstraint( + "status IN ('active', 'disabled', 'expired')", + name="invite_codes_status_check", + ), + CheckConstraint("used_count >= 0", name="invite_codes_used_count_check"), + CheckConstraint( + "max_uses IS NULL OR max_uses >= 1", + name="invite_codes_max_uses_check", + ), + {"extend_existing": True}, + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + code: Mapped[str] = mapped_column( + String(6), + nullable=False, + unique=True, + index=True, + ) + owner_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("profiles.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + status: Mapped[str] = mapped_column( + String(20), + nullable=False, + default=InviteCodeStatus.ACTIVE.value, + ) + used_count: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + ) + max_uses: Mapped[int | None] = mapped_column( + Integer, + nullable=True, + ) + expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + reward_config: Mapped[dict[str, object]] = mapped_column( + json_jsonb, + nullable=False, + server_default="{}", + ) diff --git a/backend/src/schemas/domain/invite_code.py b/backend/src/schemas/domain/invite_code.py index 06b51c9..7e61187 100644 --- a/backend/src/schemas/domain/invite_code.py +++ b/backend/src/schemas/domain/invite_code.py @@ -1,11 +1,51 @@ from __future__ import annotations +from datetime import datetime from typing import ClassVar +from uuid import UUID -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field + +from schemas.enums import InviteCodeStatus class InviteCodeRewardConfig(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow") pass + + +class InviteCodeBase(BaseModel): + code: str = Field( + ..., + min_length=6, + max_length=6, + pattern=r"^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$", + ) + status: InviteCodeStatus = InviteCodeStatus.ACTIVE + used_count: int = Field(default=0, ge=0) + max_uses: int | None = Field(default=None, ge=1) + expires_at: datetime | None = None + reward_config: InviteCodeRewardConfig = Field( + default_factory=InviteCodeRewardConfig + ) + + +class InviteCodeCreate(InviteCodeBase): + owner_id: UUID | None = None + + +class InviteCodeUpdate(BaseModel): + status: InviteCodeStatus | None = None + max_uses: int | None = Field(default=None, ge=1) + expires_at: datetime | None = None + reward_config: InviteCodeRewardConfig | None = None + + +class InviteCodeRead(InviteCodeBase): + model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True) + + id: UUID + owner_id: UUID | None + created_at: datetime + updated_at: datetime