CoolFace
Apppublic

Miyaka/alertify-system2

sourceHugging Faceupdated 8mo agoView on Hugging Face
0likes
app.py829 linesDownload Raw Back to root
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