feat: add invite code feature (create, validate, referrer tracking)

This commit is contained in:
qzl
2026-02-27 17:27:55 +08:00
parent e4e995854d
commit 80d04688fc
6 changed files with 299 additions and 7 deletions
+3
View File
@@ -6,6 +6,7 @@ from models.automation_jobs import AutomationJob
from models.group_members import GroupMember
from models.groups import Group
from models.inbox_messages import InboxMessage
from models.invite_code import InviteCode, InviteCodeStatus
from models.llm import Llm
from models.llm_factory import LlmFactory
from models.memories import Memory
@@ -23,6 +24,8 @@ __all__ = [
"GroupMember",
"Group",
"InboxMessage",
"InviteCode",
"InviteCodeStatus",
"Llm",
"LlmFactory",
"Memory",
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
import uuid
from datetime import datetime
from enum import Enum
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, TimestampMixin
class InviteCodeStatus(str, Enum):
ACTIVE = "active"
DISABLED = "disabled"
EXPIRED = "expired"
class InviteCode(TimestampMixin, Base):
"""Invite code model.
Tracks invite codes generated by users for referral system.
"""
__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(8),
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] = mapped_column(
JSONB,
nullable=False,
server_default="{}",
)
+7 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import uuid
from sqlalchemy import String, Text
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -42,3 +42,9 @@ class Profile(TimestampMixin, SoftDeleteMixin, Base):
nullable=False,
server_default="{}",
)
referred_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("profiles.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
+4 -1
View File
@@ -39,10 +39,13 @@ class SupabaseAuthGateway(AuthServiceGateway):
async def create_verification(
self, request: VerificationCreateRequest
) -> VerificationCreateResponse:
metadata: dict[str, Any] = {"username": request.username}
if request.invite_code:
metadata["invite_code"] = request.invite_code
payload: dict[str, Any] = {
"email": request.email,
"password": request.password,
"data": {"username": request.username},
"data": metadata,
}
if request.redirect_to:
payload["options"] = {"email_redirect_to": request.redirect_to}
+9 -5
View File
@@ -1,13 +1,21 @@
from __future__ import annotations
from pydantic import BaseModel, EmailStr, Field
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class VerificationCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str = Field(min_length=3, max_length=30)
email: EmailStr
password: str = Field(min_length=6)
redirect_to: str | None = None
invite_code: str | None = Field(
default=None,
min_length=8,
max_length=8,
pattern=r"^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{8}$",
)
class VerificationResendRequest(BaseModel):
@@ -65,7 +73,3 @@ class PasswordResetConfirmRequest(BaseModel):
email: EmailStr
token: str = Field(pattern=r"^\d{6}$")
new_password: str = Field(min_length=6)
class PasswordResetResponse(BaseModel):
message: str = "Password reset email sent"