diff --git a/alembic/versions/0009_azure_omg_number.py b/alembic/versions/0009_azure_omg_number.py new file mode 100644 index 0000000..16271d4 --- /dev/null +++ b/alembic/versions/0009_azure_omg_number.py @@ -0,0 +1,26 @@ +"""Add omg_number column to azure_work_items. + +Revision ID: 0009 +Revises: 0008 +Create Date: 2026-05-13 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0009" +down_revision = "0008" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "azure_work_items", + sa.Column("omg_number", sa.String(100), nullable=False, server_default=""), + ) + op.create_index("ix_azure_work_items_omg_number", "azure_work_items", ["omg_number"]) + + +def downgrade(): + op.drop_index("ix_azure_work_items_omg_number", "azure_work_items") + op.drop_column("azure_work_items", "omg_number") diff --git a/scripts/backfill_omg_from_projects.py b/scripts/backfill_omg_from_projects.py new file mode 100644 index 0000000..16d54cd --- /dev/null +++ b/scripts/backfill_omg_from_projects.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Backfill omg_entries from projects that have a job_number but no matching entry. + docker compose exec app python scripts/backfill_omg_from_projects.py +""" +import asyncio +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from sqlalchemy import select + +from src.database import AsyncSessionLocal +from src.models import OmgEntry, Project + + +async def main(): + async with AsyncSessionLocal() as db: + result = await db.execute( + select(Project).where(Project.job_number != "") + ) + projects = result.scalars().all() + + created = 0 + for proj in projects: + existing = await db.execute( + select(OmgEntry).where( + OmgEntry.user_id == proj.user_id, + OmgEntry.job_number == proj.job_number, + ) + ) + if existing.scalar_one_or_none(): + continue + db.add(OmgEntry( + user_id=proj.user_id, + name=proj.display_name, + client=proj.client, + job_number=proj.job_number, + )) + created += 1 + + await db.commit() + print(f"Backfilled {created} OMG entries from {len(projects)} projects with job_number.") + + +asyncio.run(main()) diff --git a/src/models.py b/src/models.py index e821759..9d313a0 100644 --- a/src/models.py +++ b/src/models.py @@ -246,6 +246,7 @@ class AzureWorkItem(Base): area_path: Mapped[str] = mapped_column(String(500), default="") url: Mapped[str] = mapped_column(String(1000), default="") fields_json: Mapped[dict] = mapped_column(JSONB, default=dict) + omg_number: Mapped[str] = mapped_column(String(100), default="", index=True) synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) @property diff --git a/src/routers/auth.py b/src/routers/auth.py index 2b6acad..59cf1d0 100644 --- a/src/routers/auth.py +++ b/src/routers/auth.py @@ -1,17 +1,36 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from src.auth import ( CurrentUser, create_access_token, create_refresh_token, - decode_token, hash_password, + decode_token, ) from src.config import settings from src.database import get_db from src.models import User -from src.schemas import MicrosoftLoginRequest, RefreshRequest, TokenResponse, UserOut +from src.schemas import MicrosoftLoginRequest, TokenResponse, UserOut from src.sso import validate_microsoft_id_token +_REFRESH_COOKIE = "refresh_token" +_REFRESH_MAX_AGE = settings.REFRESH_TOKEN_EXPIRE_DAYS * 86400 + + +def _set_refresh_cookie(response: Response, token: str) -> None: + response.set_cookie( + key=_REFRESH_COOKIE, + value=token, + httponly=True, + secure=not settings.DEBUG, + samesite="lax", + max_age=_REFRESH_MAX_AGE, + path="/api/auth", + ) + + +def _clear_refresh_cookie(response: Response) -> None: + response.delete_cookie(key=_REFRESH_COOKIE, path="/api/auth") + router = APIRouter(prefix="/api/auth", tags=["auth"]) @@ -20,7 +39,7 @@ def _admin_set() -> set[str]: @router.post("/microsoft", response_model=TokenResponse) -async def microsoft_sso(body: MicrosoftLoginRequest, db: AsyncSession = Depends(get_db)): +async def microsoft_sso(body: MicrosoftLoginRequest, response: Response, db: AsyncSession = Depends(get_db)): claims = validate_microsoft_id_token(body.id_token) raw_email = claims.get("preferred_username") or claims.get("email") or "" @@ -68,24 +87,30 @@ async def microsoft_sso(body: MicrosoftLoginRequest, db: AsyncSession = Depends( await db.commit() await db.refresh(user) - return TokenResponse( - access_token=create_access_token(user.id, user.role), - refresh_token=create_refresh_token(user.id), - ) + refresh_token = create_refresh_token(user.id) + _set_refresh_cookie(response, refresh_token) + return TokenResponse(access_token=create_access_token(user.id, user.role)) @router.post("/refresh", response_model=TokenResponse) -async def refresh(body: RefreshRequest, db: AsyncSession = Depends(get_db)): - payload = decode_token(body.refresh_token) +async def refresh(request: Request, response: Response, db: AsyncSession = Depends(get_db)): + raw = request.cookies.get(_REFRESH_COOKIE) + if not raw: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing refresh token") + payload = decode_token(raw) if payload.get("type") != "refresh": raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type") user = await db.get(User, payload["sub"]) if not user or not user.is_active: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") - return TokenResponse( - access_token=create_access_token(user.id, user.role), - refresh_token=create_refresh_token(user.id), - ) + new_refresh = create_refresh_token(user.id) + _set_refresh_cookie(response, new_refresh) + return TokenResponse(access_token=create_access_token(user.id, user.role)) + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +async def logout(response: Response): + _clear_refresh_cookie(response) @router.get("/me", response_model=UserOut) diff --git a/src/routers/projects.py b/src/routers/projects.py index 9e8a597..96d8b34 100644 --- a/src/routers/projects.py +++ b/src/routers/projects.py @@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.auth import CurrentUser from src.database import get_db -from src.models import Project +from src.models import OmgEntry, Project from src.schemas import ProjectOut router = APIRouter(prefix="/api/projects", tags=["projects"]) @@ -28,6 +28,9 @@ async def update_project( project = await db.get(Project, project_id) if not project or project.user_id != user.id: raise HTTPException(status_code=404, detail="Project not found") + + old_job_number = project.job_number + if "display_name" in body: project.display_name = str(body["display_name"])[:255] if "client" in body: @@ -36,6 +39,61 @@ async def update_project( project.job_number = str(body["job_number"])[:100] if "repo_url" in body: project.repo_url = str(body["repo_url"])[:500] + + await _sync_omg_entry(user.id, project, old_job_number, body, db) + await db.commit() await db.refresh(project) return project + + +async def _sync_omg_entry( + user_id: str, + project: Project, + old_job_number: str, + changed_fields: dict, + db: AsyncSession, +) -> None: + """Keep omg_entries in sync when project job_number / name / client changes.""" + job_changed = "job_number" in changed_fields + meta_changed = "display_name" in changed_fields or "client" in changed_fields + + if job_changed and old_job_number != project.job_number: + # Remove stale entry for the old number + if old_job_number: + res = await db.execute( + select(OmgEntry).where( + OmgEntry.user_id == user_id, + OmgEntry.job_number == old_job_number, + ) + ) + old_entry = res.scalar_one_or_none() + if old_entry: + await db.delete(old_entry) + + if project.job_number: + await _upsert_omg(user_id, project, db) + + elif meta_changed and project.job_number: + # Name or client changed but job_number stayed — update existing entry if any + await _upsert_omg(user_id, project, db) + + +async def _upsert_omg(user_id: str, project: Project, db: AsyncSession) -> None: + res = await db.execute( + select(OmgEntry).where( + OmgEntry.user_id == user_id, + OmgEntry.job_number == project.job_number, + ) + ) + entry = res.scalar_one_or_none() + if entry: + entry.name = project.display_name + entry.client = project.client + else: + db.add(OmgEntry( + user_id=user_id, + name=project.display_name, + client=project.client, + job_number=project.job_number, + )) diff --git a/src/schemas.py b/src/schemas.py index b02dee5..5ab4999 100644 --- a/src/schemas.py +++ b/src/schemas.py @@ -12,7 +12,6 @@ class MicrosoftLoginRequest(BaseModel): class TokenResponse(BaseModel): access_token: str - refresh_token: str token_type: str = "bearer" @@ -382,6 +381,7 @@ class AzureWorkItemOut(BaseModel): team_project: str = "" priority: int = 3 created_date: str = "" + omg_number: str = "" fields_json: dict = {} model_config = {"from_attributes": True} diff --git a/src/services/azure_devops/sync.py b/src/services/azure_devops/sync.py index 3b091f0..0b057d7 100644 --- a/src/services/azure_devops/sync.py +++ b/src/services/azure_devops/sync.py @@ -33,6 +33,10 @@ FIELDS = [ "Microsoft.VSTS.Common.Priority", ] +# ADO custom field for OMG deliverable number — verify exact key after first sync +# by inspecting fields_json in the DevOps UI or /api/devops/work-items endpoint +OMG_DELIVERABLE_FIELD = "Custom.OMGDeliverableNumber" + async def sync_user_work_items(user: User, db: AsyncSession) -> int: """Sync ADO work items for a single user. Returns count of upserted items.""" @@ -88,6 +92,7 @@ async def sync_user_work_items(user: User, db: AsyncSession) -> int: wi.area_path = f.get("System.AreaPath", "") wi.url = f"https://dev.azure.com/{integ.organization}/_workitems/edit/{ado_id}" wi.fields_json = f + wi.omg_number = str(f.get(OMG_DELIVERABLE_FIELD) or "").strip() wi.synced_at = datetime.now(timezone.utc) # Soft-archive items no longer in ADO result set diff --git a/src/static/assets/AdminView-B3EfZ2Bv.js b/src/static/assets/AdminView-CzfCBDSF.js similarity index 89% rename from src/static/assets/AdminView-B3EfZ2Bv.js rename to src/static/assets/AdminView-CzfCBDSF.js index 9a06275..46514a5 100644 --- a/src/static/assets/AdminView-B3EfZ2Bv.js +++ b/src/static/assets/AdminView-CzfCBDSF.js @@ -1 +1 @@ -import{d as _,u as y,A as h,c as r,a as t,e as n,k as v,w as d,f as g,s as m,o as s,F as b,r as k,t as a,q as u,h as A}from"./index-DJpSDPva.js";import{a as w}from"./admin-BXEwmj8v.js";import{_ as B,a as S}from"./CardContent.vue_vue_type_script_setup_true_lang-C9faiyKN.js";import{_ as f}from"./Badge.vue_vue_type_script_setup_true_lang-BPDqRYvu.js";import{_ as V}from"./Spinner.vue_vue_type_script_setup_true_lang-DiLd-UCb.js";import{a as $}from"./utils-7WVCegLb.js";const N={class:"p-6 space-y-8"},C={key:0,class:"flex items-center justify-center h-20"},D={class:"w-full"},E={class:"px-4 py-3"},F={class:"text-sm font-medium text-foreground"},R={class:"px-4 py-3 text-sm text-muted-foreground"},U={class:"px-4 py-3"},j={class:"px-4 py-3"},q={class:"px-4 py-3 text-xs text-muted-foreground"},H=_({__name:"AdminView",setup(I){const x=y(),p=g(),i=m([]),l=m(!1);return h(async()=>{if(!x.isAdmin){p.push("/");return}l.value=!0;try{const c=await w.users();i.value=c.data}finally{l.value=!1}}),(c,o)=>(s(),r("div",N,[o[1]||(o[1]=t("h2",{class:"text-lg font-semibold text-foreground"},"Admin — Users",-1)),l.value?(s(),r("div",C,[n(V,{class:"text-primary"})])):(s(),v(B,{key:1},{default:d(()=>[n(S,{class:"p-0"},{default:d(()=>[t("table",D,[o[0]||(o[0]=t("thead",null,[t("tr",{class:"border-b border-border"},[t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"User"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Email"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Role"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Status"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Joined")])],-1)),t("tbody",null,[(s(!0),r(b,null,k(i.value,e=>(s(),r("tr",{key:e.id,class:"border-b border-border last:border-0 hover:bg-muted/30"},[t("td",E,[t("p",F,a(e.username),1)]),t("td",R,a(e.email),1),t("td",U,[n(f,{variant:e.role==="admin"?"default":"secondary",class:"text-xs"},{default:d(()=>[u(a(e.role),1)]),_:2},1032,["variant"])]),t("td",j,[n(f,{variant:e.is_active?"success":"outline",class:"text-xs"},{default:d(()=>[u(a(e.is_active?"Active":"Inactive"),1)]),_:2},1032,["variant"])]),t("td",q,a(A($)(e.created_at)),1)]))),128))])])]),_:1})]),_:1}))]))}});export{H as default}; +import{d as _,u as y,A as h,c as r,a as t,e as n,k as v,w as d,f as g,s as m,o as s,F as b,r as k,t as a,q as u,h as A}from"./index-DXo25S1G.js";import{a as w}from"./admin-CpfPkRBL.js";import{_ as B,a as S}from"./CardContent.vue_vue_type_script_setup_true_lang-CtMCMZF8.js";import{_ as f}from"./Badge.vue_vue_type_script_setup_true_lang-fQCaDGBh.js";import{_ as V}from"./Spinner.vue_vue_type_script_setup_true_lang-DlENUZyI.js";import{a as $}from"./utils-7WVCegLb.js";const N={class:"p-6 space-y-8"},C={key:0,class:"flex items-center justify-center h-20"},D={class:"w-full"},E={class:"px-4 py-3"},F={class:"text-sm font-medium text-foreground"},R={class:"px-4 py-3 text-sm text-muted-foreground"},U={class:"px-4 py-3"},j={class:"px-4 py-3"},q={class:"px-4 py-3 text-xs text-muted-foreground"},H=_({__name:"AdminView",setup(I){const x=y(),p=g(),i=m([]),l=m(!1);return h(async()=>{if(!x.isAdmin){p.push("/");return}l.value=!0;try{const c=await w.users();i.value=c.data}finally{l.value=!1}}),(c,o)=>(s(),r("div",N,[o[1]||(o[1]=t("h2",{class:"text-lg font-semibold text-foreground"},"Admin — Users",-1)),l.value?(s(),r("div",C,[n(V,{class:"text-primary"})])):(s(),v(B,{key:1},{default:d(()=>[n(S,{class:"p-0"},{default:d(()=>[t("table",D,[o[0]||(o[0]=t("thead",null,[t("tr",{class:"border-b border-border"},[t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"User"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Email"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Role"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Status"),t("th",{class:"text-left text-xs font-medium text-muted-foreground px-4 py-3"},"Joined")])],-1)),t("tbody",null,[(s(!0),r(b,null,k(i.value,e=>(s(),r("tr",{key:e.id,class:"border-b border-border last:border-0 hover:bg-muted/30"},[t("td",E,[t("p",F,a(e.username),1)]),t("td",R,a(e.email),1),t("td",U,[n(f,{variant:e.role==="admin"?"default":"secondary",class:"text-xs"},{default:d(()=>[u(a(e.role),1)]),_:2},1032,["variant"])]),t("td",j,[n(f,{variant:e.is_active?"success":"outline",class:"text-xs"},{default:d(()=>[u(a(e.is_active?"Active":"Inactive"),1)]),_:2},1032,["variant"])]),t("td",q,a(A($)(e.created_at)),1)]))),128))])])]),_:1})]),_:1}))]))}});export{H as default}; diff --git a/src/static/assets/AppLayout-glZihxNz.js b/src/static/assets/AppLayout-CaSMsW5t.js similarity index 99% rename from src/static/assets/AppLayout-glZihxNz.js rename to src/static/assets/AppLayout-CaSMsW5t.js index 1428b2d..d264385 100644 --- a/src/static/assets/AppLayout-glZihxNz.js +++ b/src/static/assets/AppLayout-CaSMsW5t.js @@ -1 +1 @@ -import{d as C,u as M,c as n,b as B,a as e,F as V,r as _,t as g,h as p,j as b,o as r,k as $,w as j,i as y,n as d,m as A,l as H,R as D,g as z,p as S,q as T,K as L,f as O,e as m,T as R,s as I,v as N}from"./index-DJpSDPva.js";const P={class:"flex flex-col h-full bg-white border-r border-slate-200/80"},F={class:"flex-1 px-3 py-4 space-y-0.5 overflow-y-auto"},K={key:0,class:"absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-orange-500 rounded-r-full"},q={class:"text-sm"},E={class:"p-4 border-t border-slate-100 shrink-0"},G={class:"flex items-center gap-3 px-2 py-2 rounded-xl bg-slate-50"},U={class:"h-8 w-8 rounded-full bg-gradient-to-br from-orange-400 to-orange-600 flex items-center justify-center text-[11px] font-bold text-white shrink-0 shadow-sm shadow-orange-200"},W={class:"flex-1 min-w-0"},J={class:"text-xs font-semibold text-slate-700 truncate"},Q=C({__name:"Sidebar",emits:["close"],setup(w,{emit:v}){const a=z(),l=M(),k=v,u=[{name:"Dashboard",path:"/",icon:"grid"},{name:"Calendar",path:"/calendar",icon:"calendar"},{name:"Tasks",path:"/tasks",icon:"check-square"},{name:"OMG",path:"/omg",icon:"omg"},{name:"Projects",path:"/projects",icon:"folder"},{name:"Live Feed",path:"/live",icon:"activity"},{name:"Reports",path:"/reports",icon:"file-text"},{name:"Keys",path:"/keys",icon:"key"},{name:"DevOps",path:"/devops",icon:"devops"},{name:"Settings",path:"/settings",icon:"settings"},{name:"Admin",path:"/admin",icon:"shield",adminOnly:!0}],x=b(()=>u.filter(h=>!h.adminOnly||l.isAdmin));function s(h){return h==="/"?a.path==="/":a.path.startsWith(h)}const i=b(()=>{var t,c;return(((t=l.user)==null?void 0:t.username)??((c=l.user)==null?void 0:c.email)??"?").slice(0,2).toUpperCase()});return(h,t)=>{var c,f;return r(),n("aside",P,[t[13]||(t[13]=B('
CC Dashboard
Oliver Agency
CC Dashboard
Oliver Agency
Corporate Planning Hub
Corporate Planning Hub