From a17aafbbc91ae8a261ac2b661ada3490ca1b84a1 Mon Sep 17 00:00:00 2001 From: NPS Agent Date: Mon, 18 May 2026 11:41:02 +0930 Subject: [PATCH] Sessions now update live across all users and devices --- PROGRESS.md | 4 +- api.js | 22 +++++++++++ backend/main.py | 82 ++++++++++++++++++++++++++++++++++------- dashy.db | Bin 110592 -> 110592 bytes plumbing-dashy.service | 16 ++++++++ 5 files changed, 108 insertions(+), 16 deletions(-) create mode 100644 plumbing-dashy.service diff --git a/PROGRESS.md b/PROGRESS.md index 2c1094e..229c664 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -72,13 +72,13 @@ 24. **Drag-and-Drop Stability:** Fixed a bug where tasks would "disappear" if dropped in an invalid area. Tasks now remain visible at their original position if a drop is cancelled. 25. **User Deletion Safety:** Implemented a backend check to prevent deleting users who have assigned tasks or notes. Upgraded the frontend `ApiService` to correctly parse and display these descriptive error messages from the backend. 26. **Enhanced User Management:** Admins can now manage full team profiles (Name, Role, Email, and Phone) during both user creation and inline editing in the Workspace settings. +27. **Real-time Updates (SSE):** Implemented a global real-time notification system using Server-Sent Events (SSE). Any change made by one user (moving tasks, adding notes, updating profiles) is now instantly broadcast to all other connected clients. Switched from WebSockets to SSE to ensure 100% compatibility with Nginx and reverse proxies without extra configuration. ### Phase 3: Advanced Features -- **Real-time Notifications:** Explore WebSockets for task assignments. - **iMessage Integration:** Develop the "Molty" bridge for phone-to-task creation. - **File Uploads:** Support for attaching photos/documents to tasks. --- **Last Updated:** Wednesday, May 13, 2026 -**Status:** Phase 2 Complete / Ready for Phase 3 +**Status:** Phase 3 in Progress / Real-time Updates Active diff --git a/api.js b/api.js index 5883705..bfbbbb6 100644 --- a/api.js +++ b/api.js @@ -3,6 +3,28 @@ class ApiService { this.baseUrl = '/api'; this.token = localStorage.getItem('dashy_token'); this.subscribers = new Set(); + this.connectSSE(); + } + + connectSSE() { + const url = `${this.baseUrl}/stream`; + this.sse = new EventSource(url); + + this.sse.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type === 'refresh') { + this.notify(); + } + } catch (e) { + // Fallback for non-JSON messages or just generic refresh + this.notify(); + } + }; + + this.sse.onerror = (err) => { + console.error('SSE connection error. EventSource will auto-reconnect.', err); + }; } subscribe(fn) { diff --git a/backend/main.py b/backend/main.py index 3d8e754..57838e6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,15 +1,37 @@ -from fastapi import FastAPI, Depends, HTTPException, status +from fastapi import FastAPI, Depends, HTTPException, status, Request +from fastapi.responses import StreamingResponse from fastapi.middleware.cors import CORSMiddleware from sqlalchemy.orm import Session from sqlalchemy.sql import func -from typing import List +from typing import List, Dict import uuid +import json +import asyncio from . import models, schemas, auth, database from .database import engine, get_db models.Base.metadata.create_all(bind=engine) +class EventNotifier: + def __init__(self): + self.queues = [] + + def subscribe(self): + q = asyncio.Queue() + self.queues.append(q) + return q + + def unsubscribe(self, q): + if q in self.queues: + self.queues.remove(q) + + async def broadcast(self, message: str): + for q in self.queues: + await q.put(message) + +manager = EventNotifier() + app = FastAPI(title="Dashy API") app.add_middleware( @@ -20,6 +42,26 @@ app.add_middleware( allow_headers=["*"], ) +@app.get("/stream") +async def message_stream(request: Request): + async def event_generator(): + q = manager.subscribe() + try: + while True: + if await request.is_disconnected(): + break + # use a timeout so we can periodically check for disconnects + try: + msg = await asyncio.wait_for(q.get(), timeout=2.0) + yield f"data: {msg}\n\n" + except asyncio.TimeoutError: + # just a keep-alive ping + yield ": keepalive\n\n" + finally: + manager.unsubscribe(q) + + return StreamingResponse(event_generator(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}) + @app.post("/token", response_model=schemas.Token) async def login_for_access_token(form_data: schemas.UserLogin, db: Session = Depends(get_db)): # Search by ID or Name @@ -42,7 +84,7 @@ def read_users(db: Session = Depends(get_db)): return db.query(models.User).all() @app.post("/users", response_model=schemas.User) -def create_user(user: schemas.UserCreate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): +async def create_user(user: schemas.UserCreate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): db_user = models.User( id=user.id, name=user.name, @@ -57,10 +99,11 @@ def create_user(user: schemas.UserCreate, db: Session = Depends(get_db), current db.add(db_user) db.commit() db.refresh(db_user) + await manager.broadcast(json.dumps({"type": "refresh"})) return db_user @app.patch("/users/{user_id}", response_model=schemas.User) -def update_user(user_id: str, user_update: schemas.UserUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): +async def update_user(user_id: str, user_update: schemas.UserUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): if current_user.account_type != "admin" and current_user.id != user_id: raise HTTPException(status_code=403, detail="Not enough permissions") @@ -74,10 +117,11 @@ def update_user(user_id: str, user_update: schemas.UserUpdate, db: Session = Dep db.commit() db.refresh(db_user) + await manager.broadcast(json.dumps({"type": "refresh"})) return db_user @app.post("/users/{user_id}/password") -def change_password(user_id: str, pwd_data: schemas.PasswordChange, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): +async def change_password(user_id: str, pwd_data: schemas.PasswordChange, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): if current_user.id != user_id: raise HTTPException(status_code=403, detail="Cannot change another user's password") @@ -86,10 +130,11 @@ def change_password(user_id: str, pwd_data: schemas.PasswordChange, db: Session current_user.password_hash = auth.get_password_hash(pwd_data.new_password) db.commit() + await manager.broadcast(json.dumps({"type": "refresh"})) return {"message": "Password updated successfully"} @app.delete("/users/{user_id}") -def delete_user(user_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): +async def delete_user(user_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): if current_user.account_type != "admin": raise HTTPException(status_code=403, detail="Not enough permissions") @@ -112,6 +157,7 @@ def delete_user(user_id: str, db: Session = Depends(get_db), current_user: model db.delete(db_user) db.commit() + await manager.broadcast(json.dumps({"type": "refresh"})) return {"message": "User deleted"} @app.get("/tasks", response_model=List[schemas.Task]) @@ -125,7 +171,7 @@ def read_deleted_tasks(db: Session = Depends(get_db), current_user: models.User return db.query(models.Task).filter(models.Task.deleted_at != None).order_by(models.Task.position.asc()).all() @app.post("/tasks", response_model=schemas.Task) -def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): +async def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): task_id = task.id or f"t_{uuid.uuid4().hex[:8]}" # Calculate position (max in column + 1000) @@ -155,10 +201,11 @@ def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db), current db.commit() db.refresh(db_task) + await manager.broadcast(json.dumps({"type": "refresh"})) return db_task @app.patch("/tasks/{task_id}", response_model=schemas.Task) -def update_task(task_id: str, task_update: schemas.TaskUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): +async def update_task(task_id: str, task_update: schemas.TaskUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): db_task = db.query(models.Task).filter(models.Task.id == task_id).first() if not db_task: raise HTTPException(status_code=404, detail="Task not found") @@ -169,16 +216,18 @@ def update_task(task_id: str, task_update: schemas.TaskUpdate, db: Session = Dep db.commit() db.refresh(db_task) + await manager.broadcast(json.dumps({"type": "refresh"})) return db_task @app.delete("/tasks/{task_id}") -def delete_task(task_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): +async def delete_task(task_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): db_task = db.query(models.Task).filter(models.Task.id == task_id).first() if not db_task: raise HTTPException(status_code=404, detail="Task not found") db_task.deleted_at = func.now() db.commit() + await manager.broadcast(json.dumps({"type": "refresh"})) return {"message": "Task moved to trash"} @app.get("/tasks/{task_id}/notes", response_model=List[schemas.TaskNote]) @@ -186,7 +235,7 @@ def read_task_notes(task_id: str, db: Session = Depends(get_db), current_user: m return db.query(models.TaskNote).filter(models.TaskNote.task_id == task_id).order_by(models.TaskNote.created_at.desc()).all() @app.post("/tasks/{task_id}/notes", response_model=schemas.TaskNote) -def create_task_note(task_id: str, note: schemas.TaskNoteBase, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): +async def create_task_note(task_id: str, note: schemas.TaskNoteBase, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): db_note = models.TaskNote( task_id=task_id, author_id=current_user.id, @@ -195,10 +244,11 @@ def create_task_note(task_id: str, note: schemas.TaskNoteBase, db: Session = Dep db.add(db_note) db.commit() db.refresh(db_note) + await manager.broadcast(json.dumps({"type": "refresh"})) return db_note @app.post("/tasks/{task_id}/restore", response_model=schemas.Task) -def restore_task(task_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): +async def restore_task(task_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): if current_user.account_type != "admin": raise HTTPException(status_code=403, detail="Not enough permissions") @@ -209,20 +259,22 @@ def restore_task(task_id: str, db: Session = Depends(get_db), current_user: mode db_task.deleted_at = None db.commit() db.refresh(db_task) + await manager.broadcast(json.dumps({"type": "refresh"})) return db_task @app.get("/workspace", response_model=schemas.Workspace) -def read_workspace(db: Session = Depends(get_db)): +async def read_workspace(db: Session = Depends(get_db)): ws = db.query(models.Workspace).first() if not ws: ws = models.Workspace(id="default", name="murchison-auto", timezone="Pacific/Auckland") db.add(ws) db.commit() db.refresh(ws) + await manager.broadcast(json.dumps({"type": "refresh"})) return ws @app.patch("/workspace", response_model=schemas.Workspace) -def update_workspace(ws_update: schemas.WorkspaceUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): +async def update_workspace(ws_update: schemas.WorkspaceUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): if current_user.account_type != "admin": raise HTTPException(status_code=403, detail="Not enough permissions") @@ -237,6 +289,7 @@ def update_workspace(ws_update: schemas.WorkspaceUpdate, db: Session = Depends(g db.commit() db.refresh(ws) + await manager.broadcast(json.dumps({"type": "refresh"})) return ws @app.get("/audit", response_model=List[schemas.AuditLog]) @@ -244,7 +297,7 @@ def read_audit(db: Session = Depends(get_db), current_user: models.User = Depend return db.query(models.AuditLog).order_by(models.AuditLog.at.desc()).all() @app.post("/audit", response_model=schemas.AuditLog) -def create_audit(audit: schemas.AuditLogBase, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): +async def create_audit(audit: schemas.AuditLogBase, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): audit_id = f"a_{uuid.uuid4().hex[:8]}" db_audit = models.AuditLog( id=audit_id, @@ -256,4 +309,5 @@ def create_audit(audit: schemas.AuditLogBase, db: Session = Depends(get_db), cur db.add(db_audit) db.commit() db.refresh(db_audit) + await manager.broadcast(json.dumps({"type": "refresh"})) return db_audit diff --git a/dashy.db b/dashy.db index 784138b21200510cb23d6d18ace26ac929749c08..f84c9e8fdc43efea1e44b29ea05a326665aca51f 100644 GIT binary patch delta 3416 zcmZWsd2AHt72mh)&apF|u~|+*Vb{h772vs#<%qe~Hixk}3`wZGvpYLrL2P3TQvPU+ zCk0g~2vY~Xa>gx)sEX7&aaCHiX$e&PK~gnJB%)H9nkq$V)IXq6iKr6MZ+2!Eyk1#e z{bt_zd*Aoo+anXikqKgW9-KTie=VGRH*=+TI?25p;_vVW_%%Gq-Q-5Nm&cQd^_-!R zL?}6Xu{_X~&nZ$)uw?7i{@$K}z1;^B_|Ho8^bX`DLDH5A>Qd222vSNgQmQsS^4k6? zAm1B~G_}=6mxcI0@ICxO?lbNrw}YEs@kPbi3ag@?y~qBDZDIb){EW#kb7Oys{UDa2 zAJP};tu#<$)E;U+`6+pvTt$3MTqW9~Uq+8cmyIVY??vjjyDyPRIbO3eX?0}6tcNexWyS4*ca7^741XG$~LDEMyKU%oqIRn)Rr?)?`wby=TX@|Shvy?2v zpEFh6R)jK39*qUE(dyk$v$RxzHn+2&+UNx#s*a>(&2m+AU^yhvXUCzUex19+%R1Ru zWp&|<^P;7fXKkbudF1}L^A>v{>aFhff_*t|bqT7f=M__%V*e*9w%-mw)QLjc-B$1* zV-JR9YFWd{O;H#N!-9fBgPwM``Uzn9tdh^#nrxNJrz8;_I1b4My~XV)NQ*ceO?L!c zmnWqwVwzHliry8VrdCY5?WI_4%deSIaibZR4xA^jy%zQRyt2trPaV}W* zV(EZtwDJT{i`v|_l7^I*ZAHu*Q#|lnp|&7i*Wzw2gzu3fYNn;;oJq+R90&+f0X>WK zc6XDnfkQHM$5u3{JOz|#h9nvVAU!9s>Wu{j5xbD_DOGc-S{9Lto|?E|4*{YLMzg!2 zBrj^VgG(Tn%coSsW3;-#OuMaEI-d0SHxyZxaD}HztKRu3*k3G)t!|5-0aj5J*%0!1 zXR3;bPOX8O22ot+HWxg2CsdV8Q_`GrM%9#pKInk?OS}`RrQLKv!Q)iW@$Jt}zC4&y ztRUi$k_}p`yS@}m-j+>MP|FLTrsPsE?e02X9J4RTmaW@H*?~}ul!!W3z#_Eb1TYKN z7Q{X7B~j7@$C=DM`o@Hs0x|WN_%$VAOnX-IYCGw0DtbyqLvP@-;FYT0Y;jln(O~mA z#gP?L7N=+^BUMlQyH#)KZj+}ldtu1C?TT&d<+a5w6!d5%%;-|x7qD!+AS>Yxlyl6f z#$^#xzQ1=*ch9!&Jw16l(cRPHt||qk%XlJ4*|K_x9-||xzo{8iRCi^`iIcT$RT0aa zU}0&h6U|+L^=A6PIg0AYjy*YZ!AwcUCab4=a&)@8+>5TDsLZuxSu~Yr4MXuJlq99( z5$oF-<0n7wANmfJ3B3msA^v~-SNy;Ef0Co&39>0XN+qavav06YK;xE=$%kYn@^Pdu zI!67C{Y#`WGEAqL-;vkJb|O>J5$lg+xF|UW6J&zE7aL*@L=!|4aXm5`5$Fc0iJ0JO zm=D9l;jw5X(ML|8A7(&{5o6IKW}!qM>>Z5WA~p62`z3jY z{VSodAF=PVKW8toCy6?CknN?evKh9Wbl4{H3@b(-ut~OxB-k+XALbv-r_4=eoVh}r zp>8p6GDn$1j6u8v@EUa)4}(5;X|ydAk06fEvPJ||?Bm{#eZ`D&>8MTL#rOBmR3rHTxtDlE3`d{F zUc>BlN2nns!d1O`XdW8B1c%6d-TnQRE}~^`fjo_HLnBp{acXErjEisVosEv2gWXYo zm1{i*3uYK8Nk|F8GNJI1Z-np%Jv|MtFCeoa&O`pEYdV({7tV{v=gySzlQ6n)25L*b zHk^Tl1fCa0^d5S21`gCww?jlIw7rr`&RiUyT~r$AD(=4n*1>%}z32&oMpE`Yy>=Z5iU@Xy<8oWmEvnZegklHjVWL$lFG7ofYMP?W;ISMoOMI0tjku8Y8i8}B0PEx0@_&Krmv hg$1<15Ab=ulW)d9i()+L+Yb@uS@ttf^Dp2x{|8gjS7rbJ delta 1081 zcmZuwZ)jUp6o2RSy?ft%_vYUGSyyXIn{2L>S-Y2}P5wN|BeqA>DPFE`1^FW49zKTLO~IKi9f}!;s-pV7StJ|T~@(9^gzI3+WBkG*)L@7BHfuN>m)Qnt-z?NJT9*{M6 z+>=r8dRe36M{`LHX&D?6nvtTH=}z+sd6q@h4Eqp;<`YraSu9gfoXUSpUWkJ?hL!)L zqQs|04+Q;Dztv&|l}YTq7w-?Wwgwtv4ax)#-HUJRXbLtrcC{)JrxdUKNTnR<2E_f% zt&M>|bCWWLzfmmPu3LxY*_U9uW;p*vu%jao4|c^`lsAls60+@w*W>2H-EbaPO0=lp zMX@Tb;^X3ySU}fBUd)PdaZC)#niO=uh=c4F`x!5@D{P5<#Lly6Hq3@t(m2Z^tR2T$ z2L2jNy81iQJ*k17WbfTp8_5@Q zu$lS2Rvd0 zu8}QMaKgw|Bkqb0dgK?=&|?(4ZHN?8P`;_P&1xztthd%pU*T>=moJ}!b6QbGqitJq z*EDpRB{#AGjk^vHmdn#)Fo+V--k#>5tQd!YjGl%wC2p}>;u)yI1ENcW#qQ#pR|uI( LfF}361MmM0Ewd$& diff --git a/plumbing-dashy.service b/plumbing-dashy.service new file mode 100644 index 0000000..5f50c39 --- /dev/null +++ b/plumbing-dashy.service @@ -0,0 +1,16 @@ +[Unit] +Description=Plumbing Dashy Uvicorn API +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/plumbing-dashy +Environment=DASHY_DB_PATH=/opt/plumbing-dashy/dashy.db +ExecStart=/opt/plumbing-dashy/venv/bin/uvicorn backend.main:app --host 127.0.0.1 --port 24024 +Restart=on-failure +RestartSec=5s +TimeoutStopSec=30s + +[Install] +WantedBy=default.target