Skip to content

Commit f3589f2

Browse files
Merge pull request #27 from first-fluke/symphony/FIR-29
feat(web/settings): profile, tags, export, account deletion
2 parents bb8ee21 + bf95364 commit f3589f2

28 files changed

Lines changed: 1292 additions & 8 deletions
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""create tag table
2+
3+
Revision ID: 0004
4+
Revises: 0003
5+
Create Date: 2026-03-21
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
14+
revision: str = "0004"
15+
down_revision: Union[str, None] = "0003"
16+
branch_labels: Union[str, Sequence[str], None] = None
17+
depends_on: Union[str, Sequence[str], None] = None
18+
19+
20+
def upgrade() -> None:
21+
op.create_table(
22+
"tag",
23+
sa.Column("id", sa.Integer(), primary_key=True),
24+
sa.Column("user_id", sa.Integer(), nullable=False),
25+
sa.Column("name", sa.String(100), nullable=False),
26+
sa.Column("color", sa.String(7), nullable=False, server_default="'#6366f1'"),
27+
sa.Column(
28+
"created_at",
29+
sa.DateTime(),
30+
server_default=sa.func.now(),
31+
nullable=False,
32+
),
33+
sa.Column(
34+
"updated_at",
35+
sa.DateTime(),
36+
server_default=sa.func.now(),
37+
nullable=False,
38+
),
39+
sa.ForeignKeyConstraint(
40+
["user_id"],
41+
["user_auth.id"],
42+
name="fk_tag_user_id",
43+
ondelete="CASCADE",
44+
),
45+
sa.UniqueConstraint("user_id", "name", name="uq_tag_user_name"),
46+
)
47+
op.create_index("ix_tag_user_id", "tag", ["user_id"])
48+
49+
50+
def downgrade() -> None:
51+
op.drop_index("ix_tag_user_id", table_name="tag")
52+
op.drop_table("tag")

apps/api/src/auth/repository.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,15 @@ async def create(
4141
await self.session.commit()
4242
await self.session.refresh(user)
4343
return user
44+
45+
async def update(self, user: UserAuth, **kwargs: str | None) -> UserAuth:
46+
for key, value in kwargs.items():
47+
if value is not None:
48+
setattr(user, key, value)
49+
await self.session.commit()
50+
await self.session.refresh(user)
51+
return user
52+
53+
async def delete(self, user: UserAuth) -> None:
54+
await self.session.delete(user)
55+
await self.session.commit()

apps/api/src/auth/router.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from src.auth.schemas import (
66
LogoutRequest,
7+
ProfileUpdate,
78
RefreshRequest,
89
TokenRequest,
910
TokenResponse,
@@ -76,3 +77,28 @@ async def me(
7677
service = AuthService(session, redis)
7778
user = await service.get_me(user_id)
7879
return ApiResponse(data=UserOut.model_validate(user))
80+
81+
82+
@router.patch("/me")
83+
async def update_me(
84+
body: ProfileUpdate,
85+
user_id: int = Depends(get_current_user_id),
86+
session: AsyncSession = Depends(get_session),
87+
redis: Redis = Depends(get_redis),
88+
) -> ApiResponse[UserOut]:
89+
"""Update current user profile."""
90+
service = AuthService(session, redis)
91+
user = await service.update_me(user_id, body)
92+
return ApiResponse(data=UserOut.model_validate(user))
93+
94+
95+
@router.delete("/me")
96+
async def delete_me(
97+
user_id: int = Depends(get_current_user_id),
98+
session: AsyncSession = Depends(get_session),
99+
redis: Redis = Depends(get_redis),
100+
) -> ApiResponse[dict[str, str]]:
101+
"""Delete current user account."""
102+
service = AuthService(session, redis)
103+
await service.delete_me(user_id)
104+
return ApiResponse(data={"message": "Account deleted"})

apps/api/src/auth/schemas.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from pydantic import BaseModel
1+
from pydantic import BaseModel, Field
22

33

44
class TokenRequest(BaseModel):
@@ -30,3 +30,7 @@ class UserOut(BaseModel):
3030
avatar_url: str | None
3131

3232
model_config = {"from_attributes": True}
33+
34+
35+
class ProfileUpdate(BaseModel):
36+
name: str | None = Field(default=None, min_length=1, max_length=255)

apps/api/src/auth/service.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from src.auth.models import UserAuth
1212
from src.auth.repository import AuthRepository
13+
from src.auth.schemas import ProfileUpdate
1314
from src.lib.auth import JWT_ALGORITHM, JWT_SECRET
1415
from src.lib.exceptions import AppException, UnauthorizedException
1516

@@ -85,6 +86,18 @@ async def get_me(self, user_id: int) -> UserAuth:
8586
raise UnauthorizedException(message="User not found")
8687
return user
8788

89+
async def update_me(self, user_id: int, data: ProfileUpdate) -> UserAuth:
90+
user = await self.repo.find_by_id(user_id)
91+
if not user:
92+
raise UnauthorizedException(message="User not found")
93+
return await self.repo.update(user, name=data.name)
94+
95+
async def delete_me(self, user_id: int) -> None:
96+
user = await self.repo.find_by_id(user_id)
97+
if not user:
98+
raise UnauthorizedException(message="User not found")
99+
await self.repo.delete(user)
100+
88101
def _create_access_token(self, user: UserAuth) -> str:
89102
now = datetime.datetime.now(datetime.UTC)
90103
payload = {

apps/api/src/export/__init__.py

Whitespace-only changes.

apps/api/src/export/router.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from fastapi import APIRouter, Depends
2+
from fastapi.responses import StreamingResponse
3+
from sqlalchemy.ext.asyncio import AsyncSession
4+
5+
from src.export.service import ExportService
6+
from src.lib.auth import get_current_user_id
7+
from src.lib.database import get_session
8+
9+
router = APIRouter(prefix="/export", tags=["export"])
10+
11+
12+
@router.get("/contacts")
13+
async def export_contacts(
14+
user_id: int = Depends(get_current_user_id),
15+
session: AsyncSession = Depends(get_session),
16+
) -> StreamingResponse:
17+
"""Export contacts with experiences as CSV."""
18+
service = ExportService(session)
19+
csv_content = await service.export_contacts_csv(user_id)
20+
21+
return StreamingResponse(
22+
content=iter([csv_content]),
23+
media_type="text/csv",
24+
headers={"Content-Disposition": "attachment; filename=contacts.csv"},
25+
)

apps/api/src/export/service.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import csv
2+
import io
3+
4+
from sqlalchemy import select
5+
from sqlalchemy.ext.asyncio import AsyncSession
6+
from sqlalchemy.orm import aliased
7+
8+
from src.contacts.models import Contact
9+
from src.experiences.models import Experience
10+
from src.organizations.models import Organization
11+
12+
13+
class ExportService:
14+
def __init__(self, session: AsyncSession) -> None:
15+
self.session = session
16+
17+
async def export_contacts_csv(self, user_id: int) -> str:
18+
org = aliased(Organization)
19+
20+
stmt = (
21+
select(
22+
Contact.name,
23+
Contact.email,
24+
Contact.phone,
25+
org.name.label("organization"),
26+
Experience.role,
27+
Experience.major,
28+
)
29+
.outerjoin(Experience, Experience.contact_id == Contact.id)
30+
.outerjoin(org, org.id == Experience.organization_id)
31+
.where(Contact.user_id == user_id)
32+
.order_by(Contact.name)
33+
)
34+
35+
result = await self.session.execute(stmt)
36+
rows = result.all()
37+
38+
output = io.StringIO()
39+
writer = csv.writer(output)
40+
writer.writerow(["name", "email", "phone", "organization", "role", "major"])
41+
42+
for row in rows:
43+
writer.writerow([
44+
row.name or "",
45+
row.email or "",
46+
row.phone or "",
47+
row.organization or "",
48+
row.role or "",
49+
row.major or "",
50+
])
51+
52+
return output.getvalue()

apps/api/src/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from src.auth.router import router as auth_router
88
from src.contacts.router import router as contacts_router
99
from src.experiences.router import router as experiences_router
10+
from src.export.router import router as export_router
1011
from src.globe.router import router as globe_router
1112
from src.contacts.router import router as contacts_router
1213
from src.lib.database import engine
@@ -17,6 +18,7 @@
1718
from src.lib.redis import redis
1819
from src.meetings.router import router as meetings_router
1920
from src.organizations.router import router as organizations_router
21+
from src.tags.router import router as tags_router
2022
from src.upload.router import router as upload_router
2123

2224

@@ -42,8 +44,10 @@ async def app_exception_handler(request: Request, exc: AppException) -> JSONResp
4244
app.include_router(auth_router)
4345
app.include_router(contacts_router)
4446
app.include_router(experiences_router)
47+
app.include_router(export_router)
4548
app.include_router(globe_router)
4649
app.include_router(organizations_router)
50+
app.include_router(tags_router)
4751
app.include_router(upload_router)
4852
app.include_router(meetings_router)
4953
app.include_router(contacts_router)

apps/api/src/tags/models.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,32 @@
1-
from datetime import datetime
1+
import datetime
22

33
from sqlalchemy import ForeignKey, String, UniqueConstraint, func
4+
from sqlalchemy.orm import Mapped, mapped_column
5+
from datetime import datetime
46
from sqlalchemy.orm import Mapped, mapped_column, relationship
57

68
from src.lib.database import Base
79

810

911
class Tag(Base):
10-
__tablename__ = "tags"
12+
__tablename__ = "tag"
13+
__table_args__ = (
14+
UniqueConstraint("user_id", "name", name="uq_tag_user_name"),
15+
)
1116

1217
id: Mapped[int] = mapped_column(primary_key=True)
13-
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
18+
user_id: Mapped[int] = mapped_column(ForeignKey("user_auth.id", ondelete="CASCADE"))
1419
name: Mapped[str] = mapped_column(String(100))
20+
color: Mapped[str] = mapped_column(String(7), server_default="'#6366f1'")
21+
created_at: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
22+
updated_at: Mapped[datetime.datetime] = mapped_column(
23+
server_default=func.now(),
24+
onupdate=func.now(),
25+
)
26+
__tablename__ = "tags"
27+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
1528
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
1629
updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
17-
1830
contacts: Mapped[list["Contact"]] = relationship( # noqa: F821
1931
secondary="contact_tags", back_populates="tags"
20-
)
21-
2232
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_tag_user_name"),)

0 commit comments

Comments
 (0)