Miyaka/alertify-system2
0
1import os, json, time, threading, sqlite32from datetime import datetime3from pathlib import Path4 5from flask import Flask, request, jsonify, send_from_directory, session6from flask_cors import CORS7from werkzeug.security import generate_password_hash, check_password_hash8 9# ----------------------------------------------------------------------------10# Password hashing11#12# Werkzeug 3 defaults to scrypt. Some Python builds (notably certain macOS/13# LibreSSL combinations) may not expose hashlib.scrypt, which breaks14# check_password_hash(). We force a portable method.15# ----------------------------------------------------------------------------16 17HASH_METHOD = os.environ.get("ALERTIFY_HASH_METHOD", "pbkdf2:sha256")18 19 20def hash_password(password: str) -> str:21 """Create a hash using a portable algorithm (default: pbkdf2:sha256)."""22 return generate_password_hash(password, method=HASH_METHOD)23 24 25def verify_password(password_hash: str, password: str) -> bool:26 """Verify a password hash.27 28 If the environment lacks hashlib.scrypt, scrypt hashes can't be verified.29 In that case we return False (caller should treat as invalid credentials).30 """31 try:32 return check_password_hash(password_hash, password)33 except AttributeError:34 # e.g., "hashlib has no attribute scrypt"35 return False36 37# --- Optional AI deps (loaded lazily) ---38AI_AVAILABLE = True39try:40 import numpy as np41 import torch42 import torch.nn as nn43 from transformers import AutoTokenizer, AutoModel44 from rapidfuzz import fuzz, process45except Exception:46 AI_AVAILABLE = False47 48APP_DIR = Path(__file__).resolve().parent49PUBLIC_DIR = APP_DIR / "public"50MODEL_DIR = APP_DIR / "model"51DB_PATH = Path(os.getenv("ALERTIFY_DB", APP_DIR / "alertify.sqlite3"))52SECRET_KEY = os.getenv("SECRET_KEY", "alertify-dev-secret-change-me")53 54# -----------------------------------------------------------------------------55# App56# -----------------------------------------------------------------------------57app = Flask(__name__, static_folder=str(PUBLIC_DIR), static_url_path="")58app.secret_key = SECRET_KEY59CORS(app, supports_credentials=True)60 61# -----------------------------------------------------------------------------62# Database63# -----------------------------------------------------------------------------64 65def db():66 conn = sqlite3.connect(DB_PATH)67 conn.row_factory = sqlite3.Row68 return conn69 70 71def init_db():72 conn = db()73 cur = conn.cursor()74 cur.execute(75 """76 CREATE TABLE IF NOT EXISTS users (77 id INTEGER PRIMARY KEY AUTOINCREMENT,78 role TEXT NOT NULL DEFAULT 'citizen',79 full_name TEXT NOT NULL,80 email TEXT,81 password_hash TEXT,82 birthdate TEXT,83 age INTEGER,84 created_at TEXT NOT NULL85 )86 """87 )88 cur.execute(89 """90 CREATE TABLE IF NOT EXISTS posts (91 id INTEGER PRIMARY KEY AUTOINCREMENT,92 user_id INTEGER,93 author_name TEXT NOT NULL,94 created_at TEXT NOT NULL,95 report_mode TEXT NOT NULL DEFAULT 'text',96 text TEXT NOT NULL,97 selected_barangay TEXT,98 selected_event TEXT,99 location_type TEXT,100 location_label TEXT,101 location_source TEXT,102 disaster_type TEXT,103 urgency_level TEXT,104 ai_json TEXT,105 status TEXT NOT NULL DEFAULT 'NEW',106 ack_by INTEGER,107 ack_at TEXT,108 resolved_by INTEGER,109 resolved_at TEXT,110 feedback_text TEXT,111 feedback_at TEXT,112 FOREIGN KEY(user_id) REFERENCES users(id)113 )114 """115 )116 conn.commit()117 118 # Seed admin user (change in environment variables for production).119 # Use portable hashing to avoid scrypt issues on some platforms.120 admin_email = os.getenv("ADMIN_EMAIL", os.getenv("ALERTIFY_ADMIN_EMAIL", "admin@alertify.local"))121 admin_pass = os.getenv("ADMIN_PASSWORD", os.getenv("ALERTIFY_ADMIN_PASSWORD", "admin123"))122 123 cur.execute("SELECT id, password_hash FROM users WHERE role='admin' AND email=?", (admin_email,))124 row = cur.fetchone()125 admin_hash = hash_password(admin_pass)126 if not row:127 cur.execute(128 "INSERT INTO users(role, full_name, email, password_hash, created_at) VALUES(?,?,?,?,?)",129 ("admin", "LGU Admin", admin_email, admin_hash, now()),130 )131 conn.commit()132 else:133 # Ensure admin hash uses our configured method (important when an old DB134 # was created with scrypt).135 if row[1] and not str(row[1]).startswith(HASH_METHOD.split(":")[0] + ":"):136 cur.execute("UPDATE users SET password_hash=? WHERE id=?", (admin_hash, row[0]))137 conn.commit()138 conn.close()139 140 141def now():142 return datetime.utcnow().isoformat(timespec="seconds") + "Z"143 144 145init_db()146 147# -----------------------------------------------------------------------------148# Simple in-memory event bus for SSE149# -----------------------------------------------------------------------------150 151_subscribers = set()152_sub_lock = threading.Lock()153 154 155def publish(event: dict):156 msg = f"data: {json.dumps(event, ensure_ascii=False)}\n\n"157 with _sub_lock:158 dead = []159 for q in list(_subscribers):160 try:161 q.put(msg, block=False)162 except Exception:163 dead.append(q)164 for q in dead:165 _subscribers.discard(q)166 167 168# -----------------------------------------------------------------------------169# AI Model Loader (matches your Colab export format)170# -----------------------------------------------------------------------------171 172class Alertify3Task(nn.Module):173 def __init__(self, encoder, n_type, n_urg, n_loc, dropout_p=0.15):174 super().__init__()175 self.encoder = encoder176 hid = self.encoder.config.hidden_size177 self.dropout = nn.Dropout(dropout_p)178 self.type_head = nn.Linear(hid, n_type)179 self.urg_head = nn.Linear(hid, n_urg)180 self.loc_head = nn.Linear(hid, n_loc)181 182 def forward(self, input_ids=None, attention_mask=None, token_type_ids=None):183 out = self.encoder(input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids)184 cls = self.dropout(out.last_hidden_state[:, 0])185 lt = self.type_head(cls)186 lu = self.urg_head(cls)187 ll = self.loc_head(cls)188 logits = torch.cat([lt, lu, ll], dim=1)189 return logits190 191 192class AlertifyAI:193 def __init__(self, model_dir: Path):194 self.model_dir = model_dir195 self.ready = False196 self.err = None197 self.device = "cpu"198 self.max_len = 128199 self.type_labels = []200 self.urg_labels = []201 self.loc_labels = []202 self.loc2id = {}203 self.id2loc = {}204 self.brgys = []205 self.landmarks = []206 self.alias_index = {}207 208 self.tokenizer = None209 self.model = None210 211 @staticmethod212 def _softmax(x: "np.ndarray"):213 x = x - x.max(axis=1, keepdims=True)214 e = np.exp(x)215 return e / (e.sum(axis=1, keepdims=True) + 1e-12)216 217 def load(self):218 if self.ready or self.err:219 return220 if not AI_AVAILABLE:221 self.err = "AI dependencies missing. Check requirements.txt on Render."222 return223 224 try:225 label_maps = json.loads((self.model_dir / "label_maps.json").read_text(encoding="utf-8"))226 self.max_len = int(label_maps.get("max_len", 128))227 self.type_labels = label_maps["type_labels"]228 self.urg_labels = label_maps["urg_labels"]229 self.loc_labels = label_maps["loc_labels"]230 self.id2loc = {int(k): v for k, v in label_maps.get("id2loc", {}).items()} if isinstance(label_maps.get("id2loc"), dict) else {i:t for i,t in enumerate(self.loc_labels)}231 self.loc2id = label_maps.get("loc2id", {t:i for i,t in enumerate(self.loc_labels)})232 233 gaz = json.loads((self.model_dir / "location_gazetteer.json").read_text(encoding="utf-8"))234 self.brgys = gaz.get("barangays", [])235 self.landmarks = gaz.get("landmarks", [])236 self.alias_index = gaz.get("alias_index", {})237 238 # Tokenizer: prefer local export239 self.tokenizer = AutoTokenizer.from_pretrained(str(self.model_dir), local_files_only=True)240 241 # Encoder: prefer local fine-tuned encoder weights if present242 enc_dir = self.model_dir / "encoder"243 if (enc_dir / "config.json").exists():244 self.encoder = AutoModel.from_pretrained(str(enc_dir), local_files_only=bool((enc_dir / "model.safetensors").exists()))245 else:246 # fallback to base model name stored in label_maps247 base_name = label_maps.get("base_model", "bert-base-multilingual-cased")248 self.encoder = AutoModel.from_pretrained(base_name)249 250 heads_path = self.model_dir / "heads.ckpt"251 ckpt = torch.load(str(heads_path), map_location="cpu")252 253 self.model = Alertify3Task(254 encoder=self.encoder,255 n_type=len(self.type_labels),256 n_urg=len(self.urg_labels),257 n_loc=len(self.loc_labels),258 dropout_p=float(ckpt.get("dropout_p", 0.15)),259 )260 self.model.type_head.load_state_dict(ckpt["type_head"])261 self.model.urg_head.load_state_dict(ckpt["urg_head"])262 self.model.loc_head.load_state_dict(ckpt["loc_head"])263 264 self.model.eval()265 torch.set_num_threads(int(os.getenv("TORCH_NUM_THREADS", "1")))266 267 self.ready = True268 except Exception as e:269 self.err = f"AI load failed: {e}"270 271 # --- Location (gazetteer + fuzzy) ---272 def extract_location(self, text: str):273 t = (text or "").lower()274 if not t:275 return ("NONE", "", 0, {"reason": "empty"})276 277 # pre-sort aliases by length so longer matches win278 aliases = getattr(self, "_alias_sorted", None)279 if aliases is None:280 self._alias_sorted = sorted(self.alias_index.keys(), key=len, reverse=True)281 aliases = self._alias_sorted282 283 for a in aliases:284 if len(a) >= 3 and a in t:285 info = self.alias_index[a]286 return (info.get("type", "NONE"), info.get("canon", ""), 95, {"method": "substring", "alias": a})287 288 # fuzzy: try capture after common tokens289 tokens = ["brgy", "brgy.", "bgy", "barangay", "pob", "poblacion", "sa", "nasa", "near", "malapit"]290 best = None291 for tok in tokens:292 idx = t.find(tok)293 if idx != -1:294 cap = t[idx:idx+60]295 cap = cap.replace(tok, "").strip()[:40]296 if cap:297 hit = process.extractOne(cap, list(self.alias_index.keys()), scorer=fuzz.WRatio)298 if hit and hit[1] >= 86:299 best = hit300 break301 if best:302 a, sc, _ = best303 info = self.alias_index[a]304 return (info.get("type", "NONE"), info.get("canon", ""), int(sc), {"method": "fuzzy", "alias": a, "score": int(sc)})305 306 return ("NONE", "", 0, {"reason": "no_match"})307 308 def predict(self, post_text: str, use_gazetteer_first=True, loc_prob_threshold=0.45):309 self.load()310 if not self.ready:311 return {312 "input_text": post_text,313 "error": self.err or "AI not ready",314 "location_type": "NONE",315 "location_label": "",316 "location_source": "NONE",317 "disaster_type": "Non-disaster",318 "urgency_level": "NON-URGENT",319 }320 321 g_typ, g_label, g_conf, g_dbg = self.extract_location(post_text)322 323 with torch.no_grad():324 enc = self.tokenizer(325 post_text,326 truncation=True,327 padding="max_length",328 max_length=self.max_len,329 return_tensors="pt",330 )331 logits = self.model(**enc).detach().cpu().numpy()332 333 a = len(self.type_labels)334 b = a + len(self.urg_labels)335 336 logits_type = logits[:, :a]337 logits_urg = logits[:, a:b]338 logits_loc = logits[:, b:]339 340 p_type = int(logits_type.argmax(axis=1)[0])341 p_urg = int(logits_urg.argmax(axis=1)[0])342 p_loc = int(logits_loc.argmax(axis=1)[0])343 344 loc_probs = self._softmax(logits_loc)345 loc_prob = float(loc_probs[0, p_loc])346 ml_loc_label = self.loc_labels[p_loc] if p_loc < len(self.loc_labels) else "NONE"347 348 chosen_type, chosen_label = "NONE", ""349 chosen_src = "NONE"350 351 if use_gazetteer_first and g_typ != "NONE":352 chosen_type, chosen_label, chosen_src = g_typ, g_label, f"GAZETTEER({g_conf})"353 else:354 if ml_loc_label != "NONE" and loc_prob >= loc_prob_threshold:355 if ml_loc_label in self.brgys:356 chosen_type = "BARANGAY"357 elif ml_loc_label in self.landmarks:358 chosen_type = "LANDMARK"359 else:360 chosen_type = "LANDMARK"361 chosen_label, chosen_src = ml_loc_label, f"MODEL(p={loc_prob:.2f})"362 elif g_typ != "NONE":363 chosen_type, chosen_label, chosen_src = g_typ, g_label, f"GAZETTEER({g_conf})"364 365 out = {366 "input_text": post_text,367 "location_type": chosen_type,368 "location_label": chosen_label,369 "location_source": chosen_src,370 "gazetteer_debug": g_dbg,371 "disaster_type": self.type_labels[p_type] if p_type < len(self.type_labels) else "Non-disaster",372 "urgency_level": self.urg_labels[p_urg] if p_urg < len(self.urg_labels) else "NON-URGENT",373 "ml_location_top": ml_loc_label,374 "ml_location_prob": loc_prob,375 }376 377 # enforce non-disaster consistency378 if out["disaster_type"].lower() in ("non-disaster", "nondisaster", "non disaster"):379 out["urgency_level"] = "NON-URGENT"380 out["location_type"] = "NONE"381 out["location_label"] = ""382 out["location_source"] = "NONE"383 384 return out385 386 387ai = AlertifyAI(MODEL_DIR)388 389# -----------------------------------------------------------------------------390# Auth helpers391# -----------------------------------------------------------------------------392 393 394def current_user():395 uid = session.get("uid")396 if not uid:397 return None398 conn = db()399 row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()400 conn.close()401 return dict(row) if row else None402 403 404def require_login(role=None):405 u = current_user()406 if not u:407 return None, (jsonify({"error": "Not logged in"}), 401)408 if role and u.get("role") != role:409 return None, (jsonify({"error": "Not authorized"}), 403)410 return u, None411 412 413# -----------------------------------------------------------------------------414# Static pages415# -----------------------------------------------------------------------------416 417@app.get("/")418def home():419 return send_from_directory(PUBLIC_DIR, "index.html")420 421 422@app.get("/<path:path>")423def static_proxy(path):424 # serve everything from public425 full = PUBLIC_DIR / path426 if full.is_file():427 return send_from_directory(PUBLIC_DIR, path)428 return send_from_directory(PUBLIC_DIR, "index.html")429 430 431# -----------------------------------------------------------------------------432# Auth APIs433# -----------------------------------------------------------------------------434 435@app.post("/api/auth/quick-login")436def quick_login():437 name = (request.json or {}).get("name", "").strip()438 if not name:439 return jsonify({"error": "Name is required"}), 400440 441 conn = db()442 # quick accounts: role citizen, email null, password null443 row = conn.execute("SELECT * FROM users WHERE role='citizen' AND full_name=? AND email IS NULL", (name,)).fetchone()444 if not row:445 conn.execute(446 "INSERT INTO users(role, full_name, email, password_hash, birthdate, age, created_at) VALUES(?,?,?,?,?,?,?)",447 ("citizen", name, None, None, None, None, now()),448 )449 conn.commit()450 row = conn.execute("SELECT * FROM users WHERE role='citizen' AND full_name=? AND email IS NULL ORDER BY id DESC LIMIT 1", (name,)).fetchone()451 conn.close()452 453 session["uid"] = int(row["id"])454 return jsonify({"ok": True})455 456 457@app.post("/api/auth/register")458def register():459 data = request.json or {}460 # Support both "full_name" and "name" to match existing HTML/JS forms.461 full_name = (data.get("full_name") or data.get("name") or "").strip()462 email = (data.get("email") or "").strip().lower()463 password = (data.get("password") or "").strip()464 birthdate = (data.get("birthdate") or "").strip()465 age = data.get("age")466 467 if not full_name or not email or not password:468 return jsonify({"error": "Full name, email, and password are required"}), 400469 470 conn = db()471 if conn.execute("SELECT 1 FROM users WHERE email=?", (email,)).fetchone():472 conn.close()473 return jsonify({"error": "Email already registered"}), 400474 475 conn.execute(476 "INSERT INTO users(role, full_name, email, password_hash, birthdate, age, created_at) VALUES(?,?,?,?,?,?,?)",477 ("citizen", full_name, email, hash_password(password), birthdate or None, int(age) if age not in (None, "") else None, now()),478 )479 conn.commit()480 uid = conn.execute("SELECT id FROM users WHERE email=?", (email,)).fetchone()[0]481 conn.close()482 483 session["uid"] = int(uid)484 return jsonify({"ok": True})485 486 487@app.post("/api/auth/login")488def login():489 data = request.json or {}490 email = (data.get("email") or "").strip().lower()491 password = (data.get("password") or "").strip()492 if not email or not password:493 return jsonify({"error": "Email and password required"}), 400494 495 conn = db()496 row = conn.execute("SELECT * FROM users WHERE email=? AND role='citizen'", (email,)).fetchone()497 conn.close()498 499 if not row or not row["password_hash"] or not verify_password(row["password_hash"], password):500 return jsonify({"error": "Invalid credentials"}), 401501 502 session["uid"] = int(row["id"])503 return jsonify({"ok": True})504 505 506@app.post("/api/auth/admin-login")507def admin_login():508 data = request.json or {}509 email = (data.get("email") or "").strip().lower()510 password = (data.get("password") or "").strip()511 if not email or not password:512 return jsonify({"error": "Email and password required"}), 400513 514 conn = db()515 row = conn.execute("SELECT * FROM users WHERE email=? AND role='admin'", (email,)).fetchone()516 conn.close()517 518 if not row or not verify_password(row["password_hash"], password):519 return jsonify({"error": "Invalid admin credentials"}), 401520 521 session["uid"] = int(row["id"])522 return jsonify({"ok": True})523 524 525@app.post("/api/auth/logout")526def logout():527 session.pop("uid", None)528 return jsonify({"ok": True})529 530 531@app.get("/api/me")532def me():533 u = current_user()534 if not u:535 return jsonify({"logged_in": False})536 return jsonify({537 "logged_in": True,538 "id": u["id"],539 "role": u["role"],540 "full_name": u["full_name"],541 "email": u.get("email"),542 })543 544 545# -----------------------------------------------------------------------------546# Meta547# -----------------------------------------------------------------------------548 549@app.get("/api/meta/barangays")550def meta_barangays():551 # prefer model gazetteer list if available552 try:553 brgys = json.loads((MODEL_DIR / "location_gazetteer.json").read_text(encoding="utf-8")).get("barangays", [])554 except Exception:555 brgys = []556 return jsonify({"barangays": brgys})557 558 559# -----------------------------------------------------------------------------560# Posts561# -----------------------------------------------------------------------------562 563@app.get("/api/posts")564def list_posts():565 u, err = require_login()566 if err:567 return err568 569 status = request.args.get("status")570 q = "SELECT * FROM posts"571 params = []572 where = []573 if u["role"] != "admin":574 where.append("user_id=?")575 params.append(u["id"])576 if status:577 where.append("status=?")578 params.append(status)579 if where:580 q += " WHERE " + " AND ".join(where)581 q += " ORDER BY id DESC LIMIT 200"582 583 conn = db()584 rows = [dict(r) for r in conn.execute(q, params).fetchall()]585 conn.close()586 587 for r in rows:588 r["ai_json"] = json.loads(r["ai_json"]) if r.get("ai_json") else None589 return jsonify({"posts": rows})590 591 592@app.post("/api/posts")593def create_post():594 u, err = require_login()595 if err:596 return err597 598 # Accept JSON (recommended) or HTML form-encoded requests.599 data = request.get_json(silent=True) or {}600 if not data and request.form:601 data = request.form.to_dict(flat=True)602 603 text = (data.get("text") or "").strip()604 barangay = (data.get("barangay") or "").strip() or None605 event = (data.get("event") or "").strip() or None606 607 if not text:608 return jsonify({"error": "Post text is required"}), 400609 610 try:611 ai_out = ai.predict(text)612 except Exception as e:613 # Never crash the UI on inference errors; fall back to safe defaults.614 ai_out = {615 "input_text": text,616 "location_type": "NONE",617 "location_label": "",618 "location_source": f"ERROR({type(e).__name__})",619 "disaster_type": "Non-disaster",620 "urgency_level": "NON-URGENT",621 }622 623 # if user selected barangay, prefer it624 if barangay:625 ai_out["location_type"] = "BARANGAY"626 ai_out["location_label"] = barangay627 ai_out["location_source"] = "USER_SELECTED"628 629 conn = db()630 cur = conn.cursor()631 cur.execute(632 """633 INSERT INTO posts(user_id, author_name, created_at, report_mode, text,634 selected_barangay, selected_event,635 location_type, location_label, location_source,636 disaster_type, urgency_level, ai_json, status)637 VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)638 """,639 (640 u["id"],641 u["full_name"],642 now(),643 "text",644 text,645 barangay,646 event,647 ai_out.get("location_type"),648 ai_out.get("location_label"),649 ai_out.get("location_source"),650 ai_out.get("disaster_type"),651 ai_out.get("urgency_level"),652 json.dumps(ai_out, ensure_ascii=False),653 "NEW",654 ),655 )656 conn.commit()657 post_id = cur.lastrowid658 row = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone()659 conn.close()660 661 event_payload = {"type": "new_post", "post": dict(row)}662 event_payload["post"]["ai_json"] = ai_out663 publish(event_payload)664 665 return jsonify({"ok": True, "post": event_payload["post"]})666 667 668@app.post("/api/panic")669def panic():670 u, err = require_login()671 if err:672 return err673 674 data = request.json or {}675 barangay = (data.get("barangay") or "").strip()676 event = (data.get("event") or "").strip()677 if not barangay:678 return jsonify({"error": "Barangay is required"}), 400679 if not event:680 return jsonify({"error": "Event type is required"}), 400681 682 # Build a compact message; AI still runs for type/location sanity683 text = f"CRITICAL EMERGENCY! {event}. NEED HELP ASAP! Location: {barangay}, Lipa City."684 ai_out = ai.predict(text)685 ai_out["urgency_level"] = "CRITICAL"686 ai_out["location_type"] = "BARANGAY"687 ai_out["location_label"] = barangay688 ai_out["location_source"] = "USER_SELECTED"689 690 conn = db()691 cur = conn.cursor()692 cur.execute(693 """694 INSERT INTO posts(user_id, author_name, created_at, report_mode, text,695 selected_barangay, selected_event,696 location_type, location_label, location_source,697 disaster_type, urgency_level, ai_json, status)698 VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)699 """,700 (701 u["id"],702 u["full_name"],703 now(),704 "panic",705 text,706 barangay,707 event,708 ai_out.get("location_type"),709 ai_out.get("location_label"),710 ai_out.get("location_source"),711 ai_out.get("disaster_type"),712 ai_out.get("urgency_level"),713 json.dumps(ai_out, ensure_ascii=False),714 "NEW",715 ),716 )717 conn.commit()718 post_id = cur.lastrowid719 row = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone()720 conn.close()721 722 event_payload = {"type": "new_post", "post": dict(row)}723 event_payload["post"]["ai_json"] = ai_out724 publish(event_payload)725 726 return jsonify({"ok": True, "post": event_payload["post"]})727 728 729@app.post("/api/posts/<int:post_id>/ack")730def ack(post_id: int):731 u, err = require_login(role="admin")732 if err:733 return err734 735 conn = db()736 conn.execute(737 "UPDATE posts SET status='ACK', ack_by=?, ack_at=? WHERE id=? AND status='NEW'",738 (u["id"], now(), post_id),739 )740 conn.commit()741 row = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone()742 conn.close()743 744 publish({"type": "update_post", "post": dict(row)})745 return jsonify({"ok": True, "post": dict(row)})746 747 748@app.post("/api/posts/<int:post_id>/resolve")749def resolve(post_id: int):750 u, err = require_login(role="admin")751 if err:752 return err753 754 conn = db()755 conn.execute(756 "UPDATE posts SET status='RESOLVED', resolved_by=?, resolved_at=? WHERE id=?",757 (u["id"], now(), post_id),758 )759 conn.commit()760 row = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone()761 conn.close()762 763 publish({"type": "update_post", "post": dict(row)})764 return jsonify({"ok": True, "post": dict(row)})765 766 767@app.post("/api/posts/<int:post_id>/feedback")768def feedback(post_id: int):769 u, err = require_login()770 if err:771 return err772 773 text = (request.json or {}).get("feedback", "").strip()774 if not text:775 return jsonify({"error": "Feedback text required"}), 400776 777 conn = db()778 # citizens may only feedback their own posts779 if u["role"] != "admin":780 conn.execute(781 "UPDATE posts SET feedback_text=?, feedback_at=? WHERE id=? AND user_id=?",782 (text, now(), post_id, u["id"]),783 )784 else:785 conn.execute(786 "UPDATE posts SET feedback_text=?, feedback_at=? WHERE id=?",787 (text, now(), post_id),788 )789 conn.commit()790 row = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone()791 conn.close()792 793 publish({"type": "update_post", "post": dict(row)})794 return jsonify({"ok": True, "post": dict(row)})795 796 797# -----------------------------------------------------------------------------798# Server-Sent Events799# -----------------------------------------------------------------------------800 801@app.get("/api/stream")802def stream():803 u, err = require_login(role="admin")804 if err:805 return err806 807 import queue808 q = queue.Queue(maxsize=100)809 with _sub_lock:810 _subscribers.add(q)811 812 def gen():813 # initial heartbeat814 yield "retry: 1500\n\n"815 while True:816 try:817 msg = q.get(timeout=20)818 yield msg819 except queue.Empty:820 yield ": ping\n\n"821 822 from flask import Response823 return Response(gen(), mimetype="text/event-stream")824 825 826if __name__ == "__main__":827 port = int(os.getenv("PORT", "5000"))828 app.run(host="0.0.0.0", port=port, debug=True)829 