Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 147 additions & 1 deletion src/app/api/routes/brands.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import secrets
from typing import Any, Dict, Iterable, List, Mapping, MutableMapping

from fastapi import APIRouter, HTTPException, Request, status
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse

from app.core.config import get_settings
Expand All @@ -16,11 +16,16 @@
BrandNotFound,
brand_service,
)
from app.services.profile import ProfileError, ProfileService, profile_service


router = APIRouter()


def get_profile_service() -> ProfileService:
return profile_service


def _render_brand_form(
request: Request,
*,
Expand Down Expand Up @@ -53,6 +58,33 @@ def _render_brand_form(
)


def _render_pending_team_column(
request: Request,
brand_slug: str,
*,
feedback_message: str | None = None,
feedback_type: str = "info",
form_state: Dict[str, str] | None = None,
status_code: int = status.HTTP_200_OK,
) -> HTMLResponse:
templates = request.app.state.templates
context = brand_service.get_team_partial_context(brand_slug)
context.update(
Comment on lines +61 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle missing brand when rendering pending-team partial

The helper used by the invite/approve/cancel endpoints calls brand_service.get_team_partial_context(brand_slug) without guarding against BrandNotFound. Those routes catch BrandError and try to render this partial even when the slug is invalid, so a POST like /brands/unknown/co-owners results in an uncaught BrandNotFound and a 500 response instead of a 404 or a friendly error partial. Please return a proper error when the brand is missing before attempting to re-render the partial.

Useful? React with 👍 / 👎.

{
"request": request,
"feedback_message": feedback_message,
"feedback_type": feedback_type,
"form_state": form_state or {"username": "", "note": ""},
}
)
return templates.TemplateResponse(
request,
"components/brand/team_pending_column.html",
context,
status_code=status_code,
)


def _empty_member() -> Dict[str, str]:
return {
"profile_id": "",
Expand Down Expand Up @@ -438,3 +470,117 @@ async def list_brands(request: Request) -> HTMLResponse:
}
return templates.TemplateResponse(request, "pages/brand/index.html", context)


@router.post(
"/brands/{slug}/co-owners",
response_class=HTMLResponse,
name="invite_brand_co_owner",
)
async def invite_brand_co_owner(
request: Request,
slug: str,
username: str = Form(...),
note: str = Form(default=""),
profile_service: ProfileService = Depends(get_profile_service),
) -> HTMLResponse:
try:
profile_view = await profile_service.get_profile(username)
except ProfileError as exc:
return _render_pending_team_column(
request,
slug,
feedback_message=exc.message,
feedback_type="error",
form_state={"username": username, "note": note},
)

try:
brand = brand_service.get_brand(slug)
invited_by = brand.list_owners()[0].full_name if brand.list_owners() else None
brand_service.invite_co_owner(
slug,
profile_id=profile_view.profile.id,
full_name=profile_view.profile.full_name,
username=profile_view.profile.username,
expertise=profile_view.profile.preferred_aroma,
avatar_url=profile_view.profile.avatar_url,
invited_by=invited_by,
)
except BrandError as exc:
return _render_pending_team_column(
request,
slug,
feedback_message=exc.message,
feedback_type="error",
form_state={"username": username, "note": note},
)

feedback = f"Undangan co-owner dikirim ke {profile_view.profile.full_name}."
if note.strip():
feedback += " Catatan ikut diteruskan."

return _render_pending_team_column(
request,
slug,
feedback_message=feedback,
feedback_type="success",
)


@router.post(
"/brands/{slug}/co-owners/{profile_id}/approve",
response_class=HTMLResponse,
name="approve_brand_co_owner",
)
async def approve_brand_co_owner(
request: Request,
slug: str,
profile_id: str,
) -> HTMLResponse:
try:
member = brand_service.approve_co_owner(slug, profile_id)
except BrandError as exc:
return _render_pending_team_column(
request,
slug,
feedback_message=exc.message,
feedback_type="error",
)

feedback = f"{member.full_name} sekarang menjadi co-owner aktif."
return _render_pending_team_column(
request,
slug,
feedback_message=feedback,
feedback_type="success",
)


@router.delete(
"/brands/{slug}/co-owners/{profile_id}",
response_class=HTMLResponse,
name="cancel_brand_co_owner",
)
async def cancel_brand_co_owner(
request: Request,
slug: str,
profile_id: str,
) -> HTMLResponse:
try:
member = brand_service.cancel_co_owner_invite(slug, profile_id)
except BrandError as exc:
return _render_pending_team_column(
request,
slug,
feedback_message=exc.message,
feedback_type="error",
)

feedback = f"Undangan untuk {member.full_name} dibatalkan."
return _render_pending_team_column(
request,
slug,
feedback_message=feedback,
feedback_type="success",
)

19 changes: 19 additions & 0 deletions src/app/services/brands.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,25 @@ def approve_co_owner(self, brand_slug: str, profile_id: str) -> BrandMember:
return member
raise BrandError("Undangan co-owner tidak ditemukan untuk brand ini.")

def cancel_co_owner_invite(self, brand_slug: str, profile_id: str) -> BrandMember:
brand = self.get_brand(brand_slug)
for index, member in enumerate(brand.members):
if (
member.profile_id == profile_id
and member.role == "co-owner"
and member.status == "pending"
):
return brand.members.pop(index)
raise BrandError("Undangan co-owner tidak ditemukan untuk brand ini.")

def get_team_partial_context(self, brand_slug: str) -> Dict[str, Any]:
brand = self.get_brand(brand_slug)
return {
"brand": brand,
"pending_members": brand.list_pending_members(),
"active_members": brand.list_active_members(),
}

# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
Expand Down
84 changes: 84 additions & 0 deletions src/app/web/templates/components/brand/team_pending_column.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<div
id="brand-team-pending"
class="team-column"
hx-target="this"
hx-swap="outerHTML"
>
<h3>Menunggu Persetujuan</h3>
<p
class="team-feedback{% if feedback_type %} team-feedback--{{ feedback_type }}{% endif %}"
aria-live="polite"
role="status"
>
{% if feedback_message %}{{ feedback_message }}{% endif %}
</p>
{% if pending_members %}
<ul class="team-list">
{% for member in pending_members %}
<li class="team-card glass-panel team-card--pending">
{% if member.avatar_url %}
<img src="{{ member.avatar_url }}" alt="{{ member.full_name }}" loading="lazy" />
{% endif %}
<div>
<p class="team-name">{{ member.full_name }}</p>
<p class="team-role">{{ member.role|replace('-', ' ')|title }}</p>
<p class="team-expertise">{{ member.expertise or 'Menunggu konfirmasi peran' }}</p>
{% if member.invited_by %}
<p class="team-invited">Diundang oleh {{ member.invited_by }}</p>
{% endif %}
</div>
<div class="team-actions">
<form
method="post"
hx-post="{{ request.url_for('approve_brand_co_owner', slug=brand.slug, profile_id=member.profile_id) }}"
hx-target="#brand-team-pending"
hx-swap="outerHTML"
>
<button type="submit" class="button button-small">Setujui</button>
</form>
<form
method="post"
hx-delete="{{ request.url_for('cancel_brand_co_owner', slug=brand.slug, profile_id=member.profile_id) }}"
hx-target="#brand-team-pending"
hx-swap="outerHTML"
>
<button type="submit" class="button button-ghost button-small">Batalkan</button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="empty-state">Belum ada undangan co-owner yang menunggu persetujuan.</p>
{% endif %}
<form
class="invite-form glass-panel"
aria-label="Form undang co-owner"
method="post"
hx-post="{{ request.url_for('invite_brand_co_owner', slug=brand.slug) }}"
hx-target="#brand-team-pending"
hx-swap="outerHTML"
>
<h4>Undang Co-owner</h4>
<p>Masukkan username komunitas untuk meminta persetujuan mereka sebagai co-owner.</p>
<label>
<span>Username</span>
<input
type="text"
name="username"
placeholder="contoh: chandra-pratama"
value="{{ form_state.username }}"
required
/>
</label>
<label>
<span>Catatan</span>
<textarea
name="note"
rows="3"
placeholder="Jelaskan peran atau kontribusi yang diharapkan"
>{{ form_state.note }}</textarea>
</label>
<button type="submit" class="button button-secondary">Kirim Undangan</button>
</form>
</div>
46 changes: 5 additions & 41 deletions src/app/web/templates/pages/brand/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -144,47 +144,11 @@ <h3>Aktif</h3>
{% endfor %}
</ul>
</div>
<div class="team-column">
<h3>Menunggu Persetujuan</h3>
{% if brand.list_pending_members() %}
<ul class="team-list">
{% for member in brand.list_pending_members() %}
<li class="team-card glass-panel team-card--pending">
{% if member.avatar_url %}
<img src="{{ member.avatar_url }}" alt="{{ member.full_name }}" loading="lazy" />
{% endif %}
<div>
<p class="team-name">{{ member.full_name }}</p>
<p class="team-role">{{ member.role|replace('-', ' ')|title }}</p>
<p class="team-expertise">{{ member.expertise or 'Menunggu konfirmasi peran' }}</p>
{% if member.invited_by %}
<p class="team-invited">Diundang oleh {{ member.invited_by }}</p>
{% endif %}
</div>
<div class="team-actions">
<button type="button" class="button button-small">Setujui</button>
<button type="button" class="button button-ghost button-small">Batalkan</button>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="empty-state">Belum ada undangan co-owner yang menunggu persetujuan.</p>
{% endif %}
<form class="invite-form glass-panel" aria-label="Form undang co-owner">
<h4>Undang Co-owner</h4>
<p>Masukkan username komunitas untuk meminta persetujuan mereka sebagai co-owner.</p>
<label>
<span>Username</span>
<input type="text" placeholder="contoh: chandra-pratama" />
</label>
<label>
<span>Catatan</span>
<textarea rows="3" placeholder="Jelaskan peran atau kontribusi yang diharapkan"></textarea>
</label>
<button type="button" class="button button-secondary">Kirim Undangan</button>
</form>
</div>
{% set pending_members = brand.list_pending_members() %}
{% set form_state = {"username": "", "note": ""} %}
{% set feedback_message = None %}
{% set feedback_type = "info" %}
{% include "components/brand/team_pending_column.html" %}
</div>
</section>

Expand Down