feat(invite): 添加邀请码功能模块

This commit is contained in:
qzl
2026-04-07 18:43:49 +08:00
parent b22673ce49
commit c121c1092f
4 changed files with 962 additions and 1 deletions
@@ -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<InviteScreen> createState() => _InviteScreenState();
}
class _InviteScreenState extends State<InviteScreen> {
final _bindCodeController = TextEditingController();
final _formKey = GlobalKey<FormState>();
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<void> _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<void> _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<AppColorPalette>()!;
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<FormState> formKey;
final bool isBinding;
final Future<void> 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<FormState> formKey;
final bool isBinding;
final bool isGenerating;
final bool hasInviter;
final Future<void> Function() onBind;
final Future<void> Function() onGenerate;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
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),
],
),
),
),
],
);
}
}
@@ -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")
+71
View File
@@ -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="{}",
)
+41 -1
View File
@@ -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