Changes between Version 2 and Version 3 of dbdevelopment


Ignore:
Timestamp:
03/01/26 23:48:08 (2 weeks ago)
Author:
231020
Comment:

--

Legend:

Unmodified
Added
Removed
Modified
  • dbdevelopment

    v2 v3  
    1212
    1313=== Имплементација ===
     14
     15==== Прилагодени домени (Custom Domains) ====
     16
     17{{{
     18CREATE DOMAIN trade_status_domain AS VARCHAR(255)
     19CHECK (VALUE IN ('PENDING', 'COMPLETED', 'DECLINED'));
     20
     21CREATE DOMAIN trade_type_domain AS VARCHAR(255)
     22CHECK (VALUE IN ('BUY', 'SELL'));
     23
     24ALTER TABLE trade_request ALTER COLUMN status TYPE trade_status_domain;
     25ALTER TABLE trade_request ALTER COLUMN type   TYPE trade_type_domain;
     26}}}
     27
     28
     29==== Тригери ====
     30
     31BEFORE INSERT тригер на trade_request кој ги валидира BUY и SELL барањата пред да бидат зачувани.
     32{{{
     33CREATE OR REPLACE FUNCTION validate_trade_request()
     34RETURNS TRIGGER AS $$
     35DECLARE
     36    v_balance        NUMERIC(18,2);
     37    v_total_cost     NUMERIC(18,2);
     38    v_owned_quantity INTEGER;
     39    v_stock_id       BIGINT;
     40BEGIN
     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;
     86END;
     87$$ LANGUAGE plpgsql;
     88
     89CREATE TRIGGER trg_validate_trade_request
     90BEFORE INSERT ON trade_request
     91FOR EACH ROW
     92EXECUTE FUNCTION validate_trade_request();
     93}}}
     94
     95
     96==== Погледи (Views) ====
     97
     98Поглед за преглед на сите pending барања со информации за корисникот и портфолиото.
     99
     100{{{
     101CREATE OR REPLACE VIEW pending_trade_requests AS
     102SELECT
     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
     116FROM trade_request tr
     117JOIN portfolios p ON tr.portfolio_id = p.id
     118JOIN users      u ON p.user_id       = u.id
     119WHERE tr.status = 'PENDING'
     120ORDER 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
     139AFTER INSERT тригер на transactions кој автоматски го ажурира балансот на портфолиото и portfolio_holdings.
     140
     141{{{
     142CREATE OR REPLACE FUNCTION sync_portfolio_on_transaction()
     143RETURNS TRIGGER AS $$
     144DECLARE
     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);
     150BEGIN
     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;
     218END;
     219$$ LANGUAGE plpgsql;
     220
     221CREATE TRIGGER trg_sync_portfolio_on_transaction
     222AFTER INSERT ON transactions
     223FOR EACH ROW
     224EXECUTE FUNCTION sync_portfolio_on_transaction();
     225}}}
     226
     227==== Погледи (Views) ====
     228Поглед за преглед на вкупната вредност на портфолиото за секој корисник, со тековните пазарни цени.
     229
     230
     231{{{
     232CREATE OR REPLACE VIEW portfolio_summary AS
     233SELECT
     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
     244FROM users u
     245JOIN portfolios         p  ON u.id       = p.user_id
     246LEFT JOIN portfolio_holdings ph ON p.id  = ph.portfolio_id
     247LEFT JOIN stock             s  ON ph.stock_id = s.id
     248WHERE u.role = 'USER'
     249GROUP BY u.id, u.username, p.id, p.balance
     250ORDER 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
     270BEFORE INSERT OR UPDATE тригер на watchlist кој ги валидира alert праговите.
     271
     272{{{
     273CREATE OR REPLACE FUNCTION validate_watchlist_thresholds()
     274RETURNS TRIGGER AS $$
     275BEGIN
     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;
     301END;
     302$$ LANGUAGE plpgsql;
     303
     304CREATE TRIGGER trg_validate_watchlist_thresholds
     305BEFORE INSERT OR UPDATE ON watchlist
     306FOR EACH ROW
     307EXECUTE FUNCTION validate_watchlist_thresholds();
     308}}}
     309
     310==== Функции / Stored Procedures ====
     311
     312Функција која ги враќа сите watchlist записи за кои тековната цена на акцијата го надминала или паднала под зададениот праг (активни alerts).
     313
     314{{{
     315
     316CREATE OR REPLACE FUNCTION get_triggered_watchlist_alerts()
     317RETURNS 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 $$
     328BEGIN
     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;
     353END;
     354$$ LANGUAGE plpgsql;
     355}}}
     356
     357
     358==== Погледи (Views) ====
     359
     360Поглед за преглед на сите watchlist записи со тековните цени и статус на alert.
     361
     362
     363{{{
     364CREATE OR REPLACE VIEW watchlist_with_alert_status AS
     365SELECT
     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
     382FROM watchlist w
     383JOIN stock s ON w.stock_id = s.id
     384JOIN users u ON w.user_id  = u.id
     385ORDER 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
     404BEFORE INSERT тригер на oauth_pending_links кој спречува дупликат активни токени за ист корисник и провајдер.
     405
     406{{{
     407CREATE OR REPLACE FUNCTION validate_oauth_token_before_insert()
     408RETURNS TRIGGER AS $$
     409DECLARE
     410    v_existing_count INTEGER;
     411BEGIN
     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;
     433END;
     434$$ LANGUAGE plpgsql;
     435
     436CREATE TRIGGER trg_validate_oauth_token
     437BEFORE INSERT ON oauth_pending_links
     438FOR EACH ROW
     439EXECUTE FUNCTION validate_oauth_token_before_insert();
     440}}}
     441
     442==== Функции / Stored Procedures ====
     443
     444Функција за верификација на OAuth токен — проверува дали токенот постои и не е истечен, и го враќа корисничкото id и провајдерот.
     445
     446{{{
     447CREATE OR REPLACE FUNCTION verify_oauth_token(p_token VARCHAR(255))
     448RETURNS TABLE (
     449    user_id    BIGINT,
     450    provider   TEXT,
     451    email      TEXT,
     452    is_valid   BOOLEAN,
     453    reason     TEXT
     454) AS $$
     455DECLARE
     456    v_record oauth_pending_links%ROWTYPE;
     457BEGIN
     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';
     483END;
     484$$ LANGUAGE plpgsql;
     485}}}
     486
     487Процедура за рачно чистење на истечени OAuth токени (може да се повика и преку background job).
     488
     489
     490
     491{{{
     492CREATE OR REPLACE PROCEDURE cleanup_expired_oauth_tokens()
     493LANGUAGE plpgsql AS $$
     494DECLARE
     495    v_deleted_count INTEGER;
     496BEGIN
     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();
     503END;
     504$$;
     505}}}
     506
     507==== Погледи (Views) ====
     508
     509Поглед за преглед на сите OAuth токени со нивниот статус (активен / истечен).
     510
     511{{{
     512CREATE OR REPLACE VIEW oauth_tokens_status AS
     513SELECT
     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
     527FROM oauth_pending_links o
     528JOIN users u ON o.user_id = u.id
     529ORDER BY o.expires_at DESC;
     530}}}