| | 14 | |
| | 15 | ==== Прилагодени домени (Custom Domains) ==== |
| | 16 | |
| | 17 | {{{ |
| | 18 | CREATE DOMAIN trade_status_domain AS VARCHAR(255) |
| | 19 | CHECK (VALUE IN ('PENDING', 'COMPLETED', 'DECLINED')); |
| | 20 | |
| | 21 | CREATE DOMAIN trade_type_domain AS VARCHAR(255) |
| | 22 | CHECK (VALUE IN ('BUY', 'SELL')); |
| | 23 | |
| | 24 | ALTER TABLE trade_request ALTER COLUMN status TYPE trade_status_domain; |
| | 25 | ALTER TABLE trade_request ALTER COLUMN type TYPE trade_type_domain; |
| | 26 | }}} |
| | 27 | |
| | 28 | |
| | 29 | ==== Тригери ==== |
| | 30 | |
| | 31 | BEFORE INSERT тригер на trade_request кој ги валидира BUY и SELL барањата пред да бидат зачувани. |
| | 32 | {{{ |
| | 33 | CREATE OR REPLACE FUNCTION validate_trade_request() |
| | 34 | RETURNS TRIGGER AS $$ |
| | 35 | DECLARE |
| | 36 | v_balance NUMERIC(18,2); |
| | 37 | v_total_cost NUMERIC(18,2); |
| | 38 | v_owned_quantity INTEGER; |
| | 39 | v_stock_id BIGINT; |
| | 40 | BEGIN |
| | 41 | -- total cost of the request |
| | 42 | v_total_cost := NEW.price_per_unit * NEW.quantity; |
| | 43 | |
| | 44 | IF NEW.type = 'BUY' THEN |
| | 45 | -- Check - portfolio has enough balance |
| | 46 | SELECT p.balance |
| | 47 | INTO v_balance |
| | 48 | FROM portfolios p |
| | 49 | WHERE p.id = NEW.portfolio_id; |
| | 50 | |
| | 51 | IF v_balance IS NULL THEN |
| | 52 | RAISE EXCEPTION 'Portfolio with id=% not found', NEW.portfolio_id; |
| | 53 | END IF; |
| | 54 | |
| | 55 | IF v_balance < v_total_cost THEN |
| | 56 | RAISE EXCEPTION |
| | 57 | 'Insufficient balance: portfolio has %, but trade requires %', |
| | 58 | v_balance, v_total_cost; |
| | 59 | END IF; |
| | 60 | |
| | 61 | ELSIF NEW.type = 'SELL' THEN |
| | 62 | -- stock_id from symbol |
| | 63 | SELECT s.id INTO v_stock_id |
| | 64 | FROM stock s |
| | 65 | WHERE s.symbol = NEW.stock_symbol; |
| | 66 | |
| | 67 | IF v_stock_id IS NULL THEN |
| | 68 | RAISE EXCEPTION 'Stock with symbol=% not found', NEW.stock_symbol; |
| | 69 | END IF; |
| | 70 | |
| | 71 | -- Sum all stock in the portfolio |
| | 72 | SELECT COALESCE(SUM(ph.quantity), 0) |
| | 73 | INTO v_owned_quantity |
| | 74 | FROM portfolio_holdings ph |
| | 75 | WHERE ph.portfolio_id = NEW.portfolio_id |
| | 76 | AND ph.stock_id = v_stock_id; |
| | 77 | |
| | 78 | IF v_owned_quantity < NEW.quantity THEN |
| | 79 | RAISE EXCEPTION |
| | 80 | 'Insufficient holdings: portfolio owns % shares of %, but trade requires %', |
| | 81 | v_owned_quantity, NEW.stock_symbol, NEW.quantity; |
| | 82 | END IF; |
| | 83 | END IF; |
| | 84 | |
| | 85 | RETURN NEW; |
| | 86 | END; |
| | 87 | $$ LANGUAGE plpgsql; |
| | 88 | |
| | 89 | CREATE TRIGGER trg_validate_trade_request |
| | 90 | BEFORE INSERT ON trade_request |
| | 91 | FOR EACH ROW |
| | 92 | EXECUTE FUNCTION validate_trade_request(); |
| | 93 | }}} |
| | 94 | |
| | 95 | |
| | 96 | ==== Погледи (Views) ==== |
| | 97 | |
| | 98 | Поглед за преглед на сите pending барања со информации за корисникот и портфолиото. |
| | 99 | |
| | 100 | {{{ |
| | 101 | CREATE OR REPLACE VIEW pending_trade_requests AS |
| | 102 | SELECT |
| | 103 | tr.id AS trade_id, |
| | 104 | tr.type AS trade_type, |
| | 105 | tr.stock_symbol, |
| | 106 | tr.quantity, |
| | 107 | tr.price_per_unit, |
| | 108 | (tr.price_per_unit * tr.quantity)::NUMERIC AS total_value, |
| | 109 | tr.status, |
| | 110 | tr.timestamp, |
| | 111 | p.id AS portfolio_id, |
| | 112 | p.balance AS portfolio_balance, |
| | 113 | u.id AS user_id, |
| | 114 | u.username, |
| | 115 | u.email |
| | 116 | FROM trade_request tr |
| | 117 | JOIN portfolios p ON tr.portfolio_id = p.id |
| | 118 | JOIN users u ON p.user_id = u.id |
| | 119 | WHERE tr.status = 'PENDING' |
| | 120 | ORDER BY tr.timestamp DESC; |
| | 121 | }}} |
| | 122 | |
| | 123 | |
| | 124 | ---- |
| | 125 | |
| | 126 | == Автоматско ажурирање на портфолио при извршување на трансакција == |
| | 127 | |
| | 128 | === Опис на барањата за податочни ограничувања === |
| | 129 | |
| | 130 | Системот мора да обезбеди дека: |
| | 131 | * Кога се INSERT-ира нова трансакција од тип BUY, балансот на портфолиото на корисникот автоматски се намалува за (price * quantity), а во portfolio_holdings се додава или се ажурира avg_price на постоечкото холдинг за таа акција |
| | 132 | * Кога се INSERT-ира нова трансакција од тип SELL, балансот на портфолиото автоматски се зголемува за (price * quantity), а количината во portfolio_holdings се намалува; ако quantity достигне 0, записот се брише |
| | 133 | * avg_price при BUY се пресметува со weighted average формула: (стара_вредност + нова_вредност) / (стар_qty + нов_qty) |
| | 134 | |
| | 135 | === Имплементација === |
| | 136 | |
| | 137 | ==== Тригери ==== |
| | 138 | |
| | 139 | AFTER INSERT тригер на transactions кој автоматски го ажурира балансот на портфолиото и portfolio_holdings. |
| | 140 | |
| | 141 | {{{ |
| | 142 | CREATE OR REPLACE FUNCTION sync_portfolio_on_transaction() |
| | 143 | RETURNS TRIGGER AS $$ |
| | 144 | DECLARE |
| | 145 | v_portfolio_id BIGINT; |
| | 146 | v_existing_qty INTEGER; |
| | 147 | v_existing_avg NUMERIC(38,2); |
| | 148 | v_new_avg NUMERIC(38,2); |
| | 149 | v_total_value NUMERIC(38,2); |
| | 150 | BEGIN |
| | 151 | v_total_value := NEW.price * NEW.quantity; |
| | 152 | |
| | 153 | -- portfolio from user |
| | 154 | SELECT p.id INTO v_portfolio_id |
| | 155 | FROM portfolios p |
| | 156 | WHERE p.user_id = NEW.user_id; |
| | 157 | |
| | 158 | IF v_portfolio_id IS NULL THEN |
| | 159 | RAISE EXCEPTION 'No portfolio found for user_id=%', NEW.user_id; |
| | 160 | END IF; |
| | 161 | |
| | 162 | IF NEW.type = 'BUY' THEN |
| | 163 | -- balance |
| | 164 | UPDATE portfolios |
| | 165 | SET balance = balance - v_total_value |
| | 166 | WHERE id = v_portfolio_id; |
| | 167 | |
| | 168 | -- chekc if holding already exists |
| | 169 | SELECT ph.quantity, ph.avg_price |
| | 170 | INTO v_existing_qty, v_existing_avg |
| | 171 | FROM portfolio_holdings ph |
| | 172 | WHERE ph.portfolio_id = v_portfolio_id |
| | 173 | AND ph.stock_id = NEW.stock_id |
| | 174 | LIMIT 1; |
| | 175 | |
| | 176 | IF v_existing_qty IS NOT NULL THEN |
| | 177 | -- weighted average price |
| | 178 | v_new_avg := ((v_existing_avg * v_existing_qty) + (NEW.price * NEW.quantity)) |
| | 179 | / (v_existing_qty + NEW.quantity); |
| | 180 | |
| | 181 | UPDATE portfolio_holdings |
| | 182 | SET quantity = quantity + NEW.quantity, |
| | 183 | avg_price = v_new_avg |
| | 184 | WHERE portfolio_id = v_portfolio_id |
| | 185 | AND stock_id = NEW.stock_id; |
| | 186 | ELSE |
| | 187 | -- insert new holding lot |
| | 188 | INSERT INTO portfolio_holdings (id, quantity, avg_price, stock_id, portfolio_id) |
| | 189 | VALUES ( |
| | 190 | nextval('portfolio_holdings_id_seq'), |
| | 191 | NEW.quantity, |
| | 192 | NEW.price, |
| | 193 | NEW.stock_id, |
| | 194 | v_portfolio_id |
| | 195 | ); |
| | 196 | END IF; |
| | 197 | |
| | 198 | ELSIF NEW.type = 'SELL' THEN |
| | 199 | -- add proceeds to balance |
| | 200 | UPDATE portfolios |
| | 201 | SET balance = balance + v_total_value |
| | 202 | WHERE id = v_portfolio_id; |
| | 203 | |
| | 204 | -- reduce holding quantity |
| | 205 | UPDATE portfolio_holdings |
| | 206 | SET quantity = quantity - NEW.quantity |
| | 207 | WHERE portfolio_id = v_portfolio_id |
| | 208 | AND stock_id = NEW.stock_id; |
| | 209 | |
| | 210 | -- remove holding if quantity reaches zero |
| | 211 | DELETE FROM portfolio_holdings |
| | 212 | WHERE portfolio_id = v_portfolio_id |
| | 213 | AND stock_id = NEW.stock_id |
| | 214 | AND quantity <= 0; |
| | 215 | END IF; |
| | 216 | |
| | 217 | RETURN NEW; |
| | 218 | END; |
| | 219 | $$ LANGUAGE plpgsql; |
| | 220 | |
| | 221 | CREATE TRIGGER trg_sync_portfolio_on_transaction |
| | 222 | AFTER INSERT ON transactions |
| | 223 | FOR EACH ROW |
| | 224 | EXECUTE FUNCTION sync_portfolio_on_transaction(); |
| | 225 | }}} |
| | 226 | |
| | 227 | ==== Погледи (Views) ==== |
| | 228 | Поглед за преглед на вкупната вредност на портфолиото за секој корисник, со тековните пазарни цени. |
| | 229 | |
| | 230 | |
| | 231 | {{{ |
| | 232 | CREATE OR REPLACE VIEW portfolio_summary AS |
| | 233 | SELECT |
| | 234 | u.id AS user_id, |
| | 235 | u.username, |
| | 236 | p.id AS portfolio_id, |
| | 237 | p.balance AS cash_balance, |
| | 238 | COALESCE(SUM(ph.quantity * s.current_price), 0) AS holdings_market_value, |
| | 239 | COALESCE(SUM(ph.quantity * ph.avg_price), 0) AS holdings_cost_basis, |
| | 240 | COALESCE(SUM(ph.quantity * s.current_price) |
| | 241 | - SUM(ph.quantity * ph.avg_price), 0) AS unrealized_pnl, |
| | 242 | p.balance + COALESCE(SUM(ph.quantity * s.current_price), 0) AS total_portfolio_value, |
| | 243 | COUNT(DISTINCT ph.stock_id) AS num_stocks_held |
| | 244 | FROM users u |
| | 245 | JOIN portfolios p ON u.id = p.user_id |
| | 246 | LEFT JOIN portfolio_holdings ph ON p.id = ph.portfolio_id |
| | 247 | LEFT JOIN stock s ON ph.stock_id = s.id |
| | 248 | WHERE u.role = 'USER' |
| | 249 | GROUP BY u.id, u.username, p.id, p.balance |
| | 250 | ORDER BY total_portfolio_value DESC; |
| | 251 | |
| | 252 | }}} |
| | 253 | |
| | 254 | |
| | 255 | ---- |
| | 256 | |
| | 257 | == Валидација на Watchlist Alert прагови == |
| | 258 | |
| | 259 | === Опис на барањата за податочни ограничувања === |
| | 260 | |
| | 261 | Системот мора да обезбеди дека: |
| | 262 | * Ако се зададени и двата прага (price_above и price_below), price_above мора да биде строго поголем од price_below; спротивното е логички невозможно |
| | 263 | * Барем еден од двата прагови мора да биде зададен (не смеат и двата да бидат NULL истовремено) |
| | 264 | * Праговите мора да бидат позитивни броеви (> 0) |
| | 265 | |
| | 266 | === Имплементација === |
| | 267 | |
| | 268 | ==== Тригери ==== |
| | 269 | |
| | 270 | BEFORE INSERT OR UPDATE тригер на watchlist кој ги валидира alert праговите. |
| | 271 | |
| | 272 | {{{ |
| | 273 | CREATE OR REPLACE FUNCTION validate_watchlist_thresholds() |
| | 274 | RETURNS TRIGGER AS $$ |
| | 275 | BEGIN |
| | 276 | -- 1one threshold must be set |
| | 277 | IF NEW.price_above IS NULL AND NEW.price_below IS NULL THEN |
| | 278 | RAISE EXCEPTION |
| | 279 | 'Watchlist entry must have at least one threshold set (price_above or price_below)'; |
| | 280 | END IF; |
| | 281 | |
| | 282 | -- thresholds must be positive |
| | 283 | IF NEW.price_above IS NOT NULL AND NEW.price_above <= 0 THEN |
| | 284 | RAISE EXCEPTION 'price_above must be a positive value, got %', NEW.price_above; |
| | 285 | END IF; |
| | 286 | |
| | 287 | IF NEW.price_below IS NOT NULL AND NEW.price_below <= 0 THEN |
| | 288 | RAISE EXCEPTION 'price_below must be a positive value, got %', NEW.price_below; |
| | 289 | END IF; |
| | 290 | |
| | 291 | -- price_above --- greater than price_below when both are set |
| | 292 | IF NEW.price_above IS NOT NULL AND NEW.price_below IS NOT NULL THEN |
| | 293 | IF NEW.price_above <= NEW.price_below THEN |
| | 294 | RAISE EXCEPTION |
| | 295 | 'price_above (%) must be strictly greater than price_below (%)', |
| | 296 | NEW.price_above, NEW.price_below; |
| | 297 | END IF; |
| | 298 | END IF; |
| | 299 | |
| | 300 | RETURN NEW; |
| | 301 | END; |
| | 302 | $$ LANGUAGE plpgsql; |
| | 303 | |
| | 304 | CREATE TRIGGER trg_validate_watchlist_thresholds |
| | 305 | BEFORE INSERT OR UPDATE ON watchlist |
| | 306 | FOR EACH ROW |
| | 307 | EXECUTE FUNCTION validate_watchlist_thresholds(); |
| | 308 | }}} |
| | 309 | |
| | 310 | ==== Функции / Stored Procedures ==== |
| | 311 | |
| | 312 | Функција која ги враќа сите watchlist записи за кои тековната цена на акцијата го надминала или паднала под зададениот праг (активни alerts). |
| | 313 | |
| | 314 | {{{ |
| | 315 | |
| | 316 | CREATE OR REPLACE FUNCTION get_triggered_watchlist_alerts() |
| | 317 | RETURNS TABLE ( |
| | 318 | watchlist_id BIGINT, |
| | 319 | user_id BIGINT, |
| | 320 | username TEXT, |
| | 321 | stock_id BIGINT, |
| | 322 | symbol TEXT, |
| | 323 | current_price DOUBLE PRECISION, |
| | 324 | price_above DOUBLE PRECISION, |
| | 325 | price_below DOUBLE PRECISION, |
| | 326 | alert_type TEXT |
| | 327 | ) AS $$ |
| | 328 | BEGIN |
| | 329 | RETURN QUERY |
| | 330 | SELECT |
| | 331 | w.id AS watchlist_id, |
| | 332 | u.id AS user_id, |
| | 333 | u.username::TEXT, |
| | 334 | s.id AS stock_id, |
| | 335 | s.symbol::TEXT, |
| | 336 | s.current_price, |
| | 337 | w.price_above, |
| | 338 | w.price_below, |
| | 339 | CASE |
| | 340 | WHEN w.price_above IS NOT NULL |
| | 341 | AND s.current_price >= w.price_above THEN 'PRICE_ABOVE_TRIGGERED' |
| | 342 | WHEN w.price_below IS NOT NULL |
| | 343 | AND s.current_price <= w.price_below THEN 'PRICE_BELOW_TRIGGERED' |
| | 344 | END::TEXT AS alert_type |
| | 345 | FROM watchlist w |
| | 346 | JOIN stock s ON w.stock_id = s.id |
| | 347 | JOIN users u ON w.user_id = u.id |
| | 348 | WHERE |
| | 349 | (w.price_above IS NOT NULL AND s.current_price >= w.price_above) |
| | 350 | OR |
| | 351 | (w.price_below IS NOT NULL AND s.current_price <= w.price_below) |
| | 352 | ORDER BY u.id, s.symbol; |
| | 353 | END; |
| | 354 | $$ LANGUAGE plpgsql; |
| | 355 | }}} |
| | 356 | |
| | 357 | |
| | 358 | ==== Погледи (Views) ==== |
| | 359 | |
| | 360 | Поглед за преглед на сите watchlist записи со тековните цени и статус на alert. |
| | 361 | |
| | 362 | |
| | 363 | {{{ |
| | 364 | CREATE OR REPLACE VIEW watchlist_with_alert_status AS |
| | 365 | SELECT |
| | 366 | w.id AS watchlist_id, |
| | 367 | u.id AS user_id, |
| | 368 | u.username, |
| | 369 | s.id AS stock_id, |
| | 370 | s.symbol, |
| | 371 | s.name AS stock_name, |
| | 372 | s.current_price, |
| | 373 | w.price_above, |
| | 374 | w.price_below, |
| | 375 | CASE |
| | 376 | WHEN w.price_above IS NOT NULL |
| | 377 | AND s.current_price >= w.price_above THEN 'PRICE_ABOVE_TRIGGERED' |
| | 378 | WHEN w.price_below IS NOT NULL |
| | 379 | AND s.current_price <= w.price_below THEN 'PRICE_BELOW_TRIGGERED' |
| | 380 | ELSE 'WATCHING' |
| | 381 | END AS alert_status |
| | 382 | FROM watchlist w |
| | 383 | JOIN stock s ON w.stock_id = s.id |
| | 384 | JOIN users u ON w.user_id = u.id |
| | 385 | ORDER BY alert_status DESC, u.username, s.symbol; |
| | 386 | }}} |
| | 387 | |
| | 388 | ---- |
| | 389 | |
| | 390 | == Управување со OAuth токени (OAuth Token Expiry Management) == |
| | 391 | |
| | 392 | === Опис на барањата за податочни ограничувања === |
| | 393 | |
| | 394 | |
| | 395 | Системот мора да обезбеди дека: |
| | 396 | * Не смее да се INSERT-ира нов OAuth токен за корисник кој веќе има активен (неистечен) токен за ист провајдер |
| | 397 | * При обид за користење на OAuth токен, системот мора да провери дали е истечен (expires_at < NOW()) и да фрли грешка ако е |
| | 398 | * Истечените токени треба автоматски да се чистат преку background job за да не се трупаат во базата |
| | 399 | |
| | 400 | === Имплементација === |
| | 401 | |
| | 402 | ==== Тригери ==== |
| | 403 | |
| | 404 | BEFORE INSERT тригер на oauth_pending_links кој спречува дупликат активни токени за ист корисник и провајдер. |
| | 405 | |
| | 406 | {{{ |
| | 407 | CREATE OR REPLACE FUNCTION validate_oauth_token_before_insert() |
| | 408 | RETURNS TRIGGER AS $$ |
| | 409 | DECLARE |
| | 410 | v_existing_count INTEGER; |
| | 411 | BEGIN |
| | 412 | -- check for existing non-expired token for same user and provider |
| | 413 | SELECT COUNT(*) INTO v_existing_count |
| | 414 | FROM oauth_pending_links |
| | 415 | WHERE user_id = NEW.user_id |
| | 416 | AND provider = NEW.provider |
| | 417 | AND expires_at > NOW(); |
| | 418 | |
| | 419 | IF v_existing_count > 0 THEN |
| | 420 | RAISE EXCEPTION |
| | 421 | 'User % already has an active OAuth token for provider %. Wait for it to expire or revoke it first.', |
| | 422 | NEW.user_id, NEW.provider; |
| | 423 | END IF; |
| | 424 | |
| | 425 | -- token is not created already expired |
| | 426 | IF NEW.expires_at <= NEW.created_at THEN |
| | 427 | RAISE EXCEPTION |
| | 428 | 'expires_at (%) must be after created_at (%)', |
| | 429 | NEW.expires_at, NEW.created_at; |
| | 430 | END IF; |
| | 431 | |
| | 432 | RETURN NEW; |
| | 433 | END; |
| | 434 | $$ LANGUAGE plpgsql; |
| | 435 | |
| | 436 | CREATE TRIGGER trg_validate_oauth_token |
| | 437 | BEFORE INSERT ON oauth_pending_links |
| | 438 | FOR EACH ROW |
| | 439 | EXECUTE FUNCTION validate_oauth_token_before_insert(); |
| | 440 | }}} |
| | 441 | |
| | 442 | ==== Функции / Stored Procedures ==== |
| | 443 | |
| | 444 | Функција за верификација на OAuth токен — проверува дали токенот постои и не е истечен, и го враќа корисничкото id и провајдерот. |
| | 445 | |
| | 446 | {{{ |
| | 447 | CREATE OR REPLACE FUNCTION verify_oauth_token(p_token VARCHAR(255)) |
| | 448 | RETURNS TABLE ( |
| | 449 | user_id BIGINT, |
| | 450 | provider TEXT, |
| | 451 | email TEXT, |
| | 452 | is_valid BOOLEAN, |
| | 453 | reason TEXT |
| | 454 | ) AS $$ |
| | 455 | DECLARE |
| | 456 | v_record oauth_pending_links%ROWTYPE; |
| | 457 | BEGIN |
| | 458 | SELECT * INTO v_record |
| | 459 | FROM oauth_pending_links |
| | 460 | WHERE token = p_token; |
| | 461 | |
| | 462 | IF v_record.token IS NULL THEN |
| | 463 | RETURN QUERY SELECT |
| | 464 | NULL::BIGINT, NULL::TEXT, NULL::TEXT, |
| | 465 | FALSE, 'Token does not exist'; |
| | 466 | RETURN; |
| | 467 | END IF; |
| | 468 | |
| | 469 | IF v_record.expires_at < NOW() THEN |
| | 470 | RETURN QUERY SELECT |
| | 471 | v_record.user_id, |
| | 472 | v_record.provider::TEXT, |
| | 473 | v_record.email::TEXT, |
| | 474 | FALSE, 'Token has expired'; |
| | 475 | RETURN; |
| | 476 | END IF; |
| | 477 | |
| | 478 | RETURN QUERY SELECT |
| | 479 | v_record.user_id, |
| | 480 | v_record.provider::TEXT, |
| | 481 | v_record.email::TEXT, |
| | 482 | TRUE, 'Token is valid'; |
| | 483 | END; |
| | 484 | $$ LANGUAGE plpgsql; |
| | 485 | }}} |
| | 486 | |
| | 487 | Процедура за рачно чистење на истечени OAuth токени (може да се повика и преку background job). |
| | 488 | |
| | 489 | |
| | 490 | |
| | 491 | {{{ |
| | 492 | CREATE OR REPLACE PROCEDURE cleanup_expired_oauth_tokens() |
| | 493 | LANGUAGE plpgsql AS $$ |
| | 494 | DECLARE |
| | 495 | v_deleted_count INTEGER; |
| | 496 | BEGIN |
| | 497 | DELETE FROM oauth_pending_links |
| | 498 | WHERE expires_at < NOW(); |
| | 499 | |
| | 500 | GET DIAGNOSTICS v_deleted_count = ROW_COUNT; |
| | 501 | |
| | 502 | RAISE NOTICE 'Cleaned up % expired OAuth token(s) at %', v_deleted_count, NOW(); |
| | 503 | END; |
| | 504 | $$; |
| | 505 | }}} |
| | 506 | |
| | 507 | ==== Погледи (Views) ==== |
| | 508 | |
| | 509 | Поглед за преглед на сите OAuth токени со нивниот статус (активен / истечен). |
| | 510 | |
| | 511 | {{{ |
| | 512 | CREATE OR REPLACE VIEW oauth_tokens_status AS |
| | 513 | SELECT |
| | 514 | o.token, |
| | 515 | o.user_id, |
| | 516 | u.username, |
| | 517 | u.email AS user_email, |
| | 518 | o.provider, |
| | 519 | o.email AS oauth_email, |
| | 520 | o.created_at, |
| | 521 | o.expires_at, |
| | 522 | CASE |
| | 523 | WHEN o.expires_at > NOW() THEN 'ACTIVE' |
| | 524 | ELSE 'EXPIRED' |
| | 525 | END AS token_status, |
| | 526 | EXTRACT(EPOCH FROM (o.expires_at - NOW()))::INTEGER AS seconds_until_expiry |
| | 527 | FROM oauth_pending_links o |
| | 528 | JOIN users u ON o.user_id = u.id |
| | 529 | ORDER BY o.expires_at DESC; |
| | 530 | }}} |