# htaccess.conf Elimination — Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Eliminate `libraries/htaccess.conf` as a template file and move all remaining hardcoded URL routes into `pp_routes`, leaving only true Apache-level directives in the generated `.htaccess`.

**Architecture:** `Helpers::htacces()` generates the full `.htaccess` content from PHP strings instead of loading a template. All URL→PHP mappings (static system routes + dynamic per language/producer) are inserted into `pp_routes` with `type='system'`, deleted and reinserted on every `htacces()` call. Apache-level rules (HTTPS redirect, admin routing, thumb.php) stay in `.htaccess` only.

**Tech Stack:** PHP 7.4, Medoo ORM (`$mdb`), Redis (CacheHandler), PHPUnit 9.6

---

## Context

### Current `Helpers::htacces()` structure (before this plan)
1. Loads `libraries/htaccess.conf` template (contains many hardcoded URL routes)
2. Appends language switch rules to `$htaccess_data`
3. Appends newsletter and producer rules to `$htaccess_data`
4. Inserts category/product/page/article routes into `pp_routes` (done in v0.329)
5. Replaces `{HTACCESS_CACHE}` placeholder
6. Appends catch-all, writes files

### What stays in `.htaccess` after this plan
- `RewriteEngine On`, `RewriteBase /`, `Options`
- www→https redirect
- http→https redirect (with tpay/przelewy24/hotpay exclusion)
- Trailing slash removal (excluding `/admin/`)
- Admin routing: `^admin/([^/]*)/([^/]*)/(.*)$`
- `^admin/$`
- Thumbnail: `^thumb/([0-9]*)/([0-9]*)/(.*)$` → `/libraries/thumb.php` (different PHP file, cannot use pp_routes)
- `THE_REQUEST` index.php redirect
- Cache headers block (gzip/expires or no-cache based on `$settings['htaccess_cache']`)
- File protection: `<Files *.conf>`, `<Files *.log>`, `<Files *.ini>`
- Start page 301 redirects (generated dynamically in pages loop)
- Custom htaccess from `pp_settings` (param=htaccess)
- Catch-all: `RewriteRule ^ index.php [L]`

### New `type` column in `pp_routes`
- `NULL` = entity route (product/category/page/article)
- `'system'` = system route (all routes in this plan)
- On every `htacces()` call: `DELETE WHERE type='system'`, then reinsert all

---

## Task 1: Update SQL migration — add `type` column

**Files:**
- Modify: `migrations/0.329.sql`

**Step 1: Add `type` column to the migration**

Open `migrations/0.329.sql` (currently has 4 lines). Append the `type` column:

```sql
ALTER TABLE pp_routes
  ADD COLUMN category_id INT NULL AFTER product_id,
  ADD COLUMN page_id INT NULL AFTER category_id,
  ADD COLUMN article_id INT NULL AFTER page_id,
  ADD COLUMN type VARCHAR(20) NULL AFTER article_id;
```

**Step 2: Apply migration on server**

Run on the production/staging database:
```sql
ALTER TABLE pp_routes ADD COLUMN type VARCHAR(20) NULL AFTER article_id;
```
(The other 3 columns from 0.329 should already be applied from the previous deployment.)

**Step 3: No test needed** — pure schema change, verified when routes are inserted in Task 2.

---

## Task 2: Refactor `Helpers::htacces()` — replace template + move all routes to pp_routes

**Files:**
- Modify: `autoload/Shared/Helpers/Helpers.php` (method `htacces()`, lines ~408–773)

This is the core task. The entire method is refactored. Here is the complete new body:

**Step 1: Replace the method body**

Find the opening of `htacces()` at line ~408. Replace everything from the start of the method body through the end (line ~773) with the code below.

The key structural changes:
- Remove `file_get_contents(htaccess.conf)` and `str_replace('{PAGE}', ...)`
- Remove `str_replace('{HTACCESS_CACHE}', ...)` — cache block is now inline
- Build `$htaccess_data` directly as PHP string
- Delete all `type='system'` routes, then reinsert static + dynamic ones
- Language switch → `pp_routes` (remove from `$htaccess_data`)
- Newsletter → `pp_routes` (remove from `$htaccess_data`)
- Producenci/producent → `pp_routes` (remove from `$htaccess_data`)

**New `htacces()` method body** — replace lines 409–773 with:

```php
  {
    global $mdb;

    $settings = ( new \Domain\Settings\SettingsRepository( $mdb ) )->allSettings( true );

    $url = preg_replace( '#^(http(s)?://)?w{3}\.#', '$1', $_SERVER['SERVER_NAME'] );

    $robots = 'User-agent: *' . PHP_EOL;
    $robots .= 'Allow: /' . PHP_EOL;

    $site_map = '<?xml version="1.0" encoding="UTF-8"?>' . PHP_EOL;
    $site_map .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . PHP_EOL;
    $site_map .= '<url>' . PHP_EOL;
    $site_map .= '<loc>https://' . $url . '</loc>' . PHP_EOL;
    $site_map .= '<lastmod>' . date( 'Y-m-d' ) . '</lastmod>' . PHP_EOL;
    $site_map .= '<changefreq>daily</changefreq>' . PHP_EOL;
    $site_map .= '<priority>1</priority>' . PHP_EOL;
    $site_map .= '</url>' . PHP_EOL;

    //
    // SYSTEM ROUTES — delete all and reinsert
    //
    $mdb->delete( 'pp_routes', [ 'type' => 'system' ] );

    // Static system routes (hardcoded, never change)
    $systemRoutes = [
      // Wyszukiwarka
      [ 'pattern' => '^wyszukiwarka/([^/]+)/([0-9]+)$', 'destination' => 'index.php?module=search&action=search_results&query=$1&bs=$2' ],
      [ 'pattern' => '^wyszukiwarka/([^/]+)$',           'destination' => 'index.php?module=search&action=search_results&query=$1&bs=1' ],
      // Zamowienia
      [ 'pattern' => '^zamowienie/([a-zA-Z0-9-]+)$',             'destination' => 'index.php?module=shop_order&action=order_details&order_hash=$1' ],
      [ 'pattern' => '^potwierdzenie-platnosci/([a-zA-Z0-9-]+)$', 'destination' => 'index.php?module=shop_order&action=payment_confirmation&order_hash=$1' ],
      // Platnosci
      [ 'pattern' => '^tpay-status$',       'destination' => 'index.php?module=shop_order&action=payment_status_tpay' ],
      [ 'pattern' => '^platnosc-status$',   'destination' => 'index.php?module=shop_order&action=payment_status_hotpay' ],
      [ 'pattern' => '^przelewy24-status$', 'destination' => 'index.php?module=shop_order&action=payment_status_przelewy24pl' ],
      // Koszyk
      [ 'pattern' => '^koszyk$',             'destination' => 'index.php?module=shop_basket&action=main_view' ],
      [ 'pattern' => '^koszyk-podsumowanie$', 'destination' => 'index.php?module=shop_basket&action=summary_view' ],
      [ 'pattern' => '^zloz-zamowienie$',    'destination' => 'index.php?module=shop_basket&action=basket_save' ],
      // Klient
      [ 'pattern' => '^rejestracja$',        'destination' => 'index.php?module=shop_client&action=register_form' ],
      [ 'pattern' => '^logowanie$',          'destination' => 'index.php?module=shop_client&action=login_form' ],
      [ 'pattern' => '^wylogowanie$',        'destination' => 'index.php?module=shop_client&action=logout' ],
      [ 'pattern' => '^odzyskiwanie-hasla$', 'destination' => 'index.php?module=shop_client&action=recover_password' ],
      [ 'pattern' => '^panel-klienta/zamowienia$',              'destination' => 'index.php?module=shop_client&action=client_orders' ],
      [ 'pattern' => '^panel-klienta/adresy$',                  'destination' => 'index.php?module=shop_client&action=client_addresses' ],
      [ 'pattern' => '^panel-klienta/nowy-adres$',              'destination' => 'index.php?module=shop_client&action=address_edit' ],
      [ 'pattern' => '^panel-klienta/edytuj-adres/([0-9]+)$',   'destination' => 'index.php?module=shop_client&action=address_edit&id=$1' ],
      [ 'pattern' => '^panel-klienta/usun-adres/([0-9]+)$',     'destination' => 'index.php?module=shop_client&action=address_delete&id=$1' ],
      // Newsletter
      [ 'pattern' => '^newsletter/signin$',              'destination' => 'index.php?module=newsletter&action=signin' ],
      [ 'pattern' => '^newsletter/confirm/hash=(.+)$',   'destination' => 'index.php?module=newsletter&action=confirm&hash=$1' ],
      [ 'pattern' => '^newsletter/unsubscribe/hash=(.+)$', 'destination' => 'index.php?module=newsletter&action=unsubscribe&hash=$1' ],
      // Moduły AJAX (shopBasket, shopClient, shopProduct, shopCoupon, search)
      [ 'pattern' => '^shopBasket/([^/]+)/(.+)$', 'destination' => 'index.php?module=shopBasket&action=$1&$2' ],
      [ 'pattern' => '^shopBasket/([^/]+)$',      'destination' => 'index.php?module=shopBasket&action=$1' ],
      [ 'pattern' => '^shopClient/([^/]+)/(.+)$', 'destination' => 'index.php?module=shopClient&action=$1&$2' ],
      [ 'pattern' => '^shopClient/([^/]+)$',      'destination' => 'index.php?module=shopClient&action=$1' ],
      [ 'pattern' => '^shopProduct/([^/]+)/(.+)$', 'destination' => 'index.php?module=shopProduct&action=$1&$2' ],
      [ 'pattern' => '^shopProduct/([^/]+)$',      'destination' => 'index.php?module=shopProduct&action=$1' ],
      [ 'pattern' => '^shopCoupon/([^/]+)/(.+)$', 'destination' => 'index.php?module=shopCoupon&action=$1&$2' ],
      [ 'pattern' => '^shopCoupon/([^/]+)$',      'destination' => 'index.php?module=shopCoupon&action=$1' ],
      [ 'pattern' => '^search/([^/]+)/(.+)$',     'destination' => 'index.php?module=search&action=$1&$2' ],
      [ 'pattern' => '^search/([^/]+)$',           'destination' => 'index.php?module=search&action=$1' ],
    ];

    foreach ( $systemRoutes as $route )
    {
      $mdb->insert( 'pp_routes', [
        'type'        => 'system',
        'lang_id'     => 0,
        'pattern'     => $route['pattern'],
        'destination' => $route['destination'],
      ] );
    }

    // Dynamic system routes — languages
    $results = $mdb->select( 'pp_langs', [ 'id' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
    if ( is_array( $results ) ) foreach ( $results as $row )
    {
      $mdb->insert( 'pp_routes', [
        'type'        => 'system',
        'lang_id'     => 0,
        'pattern'     => '^' . $row['id'] . '$',
        'destination' => 'index.php?a=change_language&id=' . $row['id'],
      ] );
    }

    // Dynamic system routes — producenci
    $categoryDefaultLayoutId = ( new \Domain\Layouts\LayoutsRepository( $mdb ) )->categoryDefaultLayoutId();

    $mdb->insert( 'pp_routes', [
      'type'        => 'system',
      'lang_id'     => 0,
      'pattern'     => '^producenci$',
      'destination' => 'index.php?module=shop_producer&action=list&layout_id=' . $categoryDefaultLayoutId,
    ] );

    $rows = $mdb->select( 'pp_shop_producer', '*', [ 'status' => 1 ] );
    if ( self::is_array_fix( $rows ) ) foreach ( $rows as $row )
    {
      $mdb->insert( 'pp_routes', [
        'type'        => 'system',
        'lang_id'     => 0,
        'pattern'     => '^producent/' . self::seo( $row['name'] ) . '$',
        'destination' => 'index.php?module=shop_producer&action=products&producer_id=' . $row['id'] . '&layout_id=' . $categoryDefaultLayoutId,
      ] );
      $mdb->insert( 'pp_routes', [
        'type'        => 'system',
        'lang_id'     => 0,
        'pattern'     => '^producent/' . self::seo( $row['name'] ) . '/([0-9]+)$',
        'destination' => 'index.php?module=shop_producer&action=products&producer_id=' . $row['id'] . '&layout_id=' . $categoryDefaultLayoutId . '&bs=$1',
      ] );
    }

    //
    // HTACCESS — generuj z PHP (bez szablonu htaccess.conf)
    //
    $htaccess_data  = 'RewriteEngine On' . PHP_EOL;
    $htaccess_data .= 'RewriteBase /' . PHP_EOL;
    $htaccess_data .= 'Options +FollowSymlinks' . PHP_EOL;
    $htaccess_data .= 'Options -Indexes' . PHP_EOL;
    $htaccess_data .= PHP_EOL;
    $htaccess_data .= '# Przekierowanie z www na bez www i z http na https w jednym kroku' . PHP_EOL;
    $htaccess_data .= 'RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]' . PHP_EOL;
    $htaccess_data .= 'RewriteRule ^ https://%1%{REQUEST_URI} [R=301,L]' . PHP_EOL;
    $htaccess_data .= PHP_EOL;
    $htaccess_data .= '# Przekierowanie z http na https, jesli nie zawiera www' . PHP_EOL;
    $htaccess_data .= 'RewriteCond %{HTTPS} off' . PHP_EOL;
    $htaccess_data .= 'RewriteCond %{REQUEST_URI} !^/(tpay-status|platnosc-status|przelewy24-status)$ [NC]' . PHP_EOL;
    $htaccess_data .= 'RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]' . PHP_EOL;
    $htaccess_data .= PHP_EOL;
    $htaccess_data .= '# Usuwanie koncowego slasha dla niekatalogów' . PHP_EOL;
    $htaccess_data .= 'RewriteCond %{REQUEST_FILENAME} !-d' . PHP_EOL;
    $htaccess_data .= 'RewriteCond %{REQUEST_URI} !^/admin/.*$ [NC]' . PHP_EOL;
    $htaccess_data .= 'RewriteCond %{REQUEST_URI} (.+)/$' . PHP_EOL;
    $htaccess_data .= 'RewriteRule ^ %1 [R=301,L]' . PHP_EOL;
    $htaccess_data .= PHP_EOL;
    $htaccess_data .= 'RewriteCond %{REQUEST_URI}  !^(.*)/libraries/(.*) [NC]' . PHP_EOL;
    $htaccess_data .= 'RewriteCond %{REQUEST_URI}  !^(.*)/layout/(.*) [NC]' . PHP_EOL;
    $htaccess_data .= 'RewriteRule ^admin/([^/]*)/([^/]*)/(.*)$ admin/index.php?module=$1&action=$2&$3 [L]' . PHP_EOL;
    $htaccess_data .= PHP_EOL;
    $htaccess_data .= 'RewriteRule ^admin/$ admin/index.php [L]' . PHP_EOL;
    $htaccess_data .= PHP_EOL;
    $htaccess_data .= 'RewriteRule ^thumb/([0-9]*)/([0-9]*)/(.*)$ /libraries/thumb.php?img=$3&w=$1&h=$2 [L]' . PHP_EOL;
    $htaccess_data .= PHP_EOL;
    $htaccess_data .= 'RewriteCond %{THE_REQUEST} ^[A-Z]{3,9}\ /index.php' . PHP_EOL;
    $htaccess_data .= 'RewriteRule ^ /%1 [R=301,L]' . PHP_EOL;

    /* cache — zastąpienie placeholdera {HTACCESS_CACHE} */
    if ( $settings['htaccess_cache'] )
    {
      $htaccess_data .= '<IfModule mod_deflate.c>' . PHP_EOL
        . 'AddOutputFilterByType DEFLATE text/html text/plain text/xml application/xml application/xhtml+xml text/css text/javascript application/javascript application/x-javascript' . PHP_EOL
        . '</IfModule>' . PHP_EOL
        . '<IfModule mod_headers.c>' . PHP_EOL
        . 'Header set Access-Control-Allow-Origin "*"' . PHP_EOL
        . '</IfModule>' . PHP_EOL
        . '<IfModule mod_expires.c>' . PHP_EOL
        . 'ExpiresActive on' . PHP_EOL
        . 'ExpiresDefault                                      "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType text/css                              "access plus 1 year"' . PHP_EOL
        . 'ExpiresByType application/json                      "access plus 0 seconds"' . PHP_EOL
        . 'ExpiresByType application/xml                       "access plus 0 seconds"' . PHP_EOL
        . 'ExpiresByType text/xml                              "access plus 0 seconds"' . PHP_EOL
        . 'ExpiresByType image/x-icon                          "access plus 1 week"' . PHP_EOL
        . 'ExpiresByType text/x-component                      "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType text/html                             "access plus 0 seconds"' . PHP_EOL
        . 'ExpiresByType application/javascript                "access plus 1 year"' . PHP_EOL
        . 'ExpiresByType application/x-web-app-manifest+json   "access plus 0 seconds"' . PHP_EOL
        . 'ExpiresByType text/cache-manifest                   "access plus 0 seconds"' . PHP_EOL
        . 'ExpiresByType audio/ogg                             "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType image/gif                             "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType image/jpeg                            "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType image/png                             "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType video/mp4                             "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType video/ogg                             "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType video/webm                            "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType application/atom+xml                  "access plus 1 hour"' . PHP_EOL
        . 'ExpiresByType application/rss+xml                   "access plus 1 hour"' . PHP_EOL
        . 'ExpiresByType application/font-woff                 "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType application/vnd.ms-fontobject         "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType application/x-font-ttf                "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType font/opentype                         "access plus 1 month"' . PHP_EOL
        . 'ExpiresByType image/svg+xml                         "access plus 1 month"' . PHP_EOL
        . '</IfModule>' . PHP_EOL;
    }
    else
    {
      $htaccess_data .= '<IfModule mod_headers.c>' . PHP_EOL
        . 'Header set Cache-Control "no-cache, no-store, must-revalidate"' . PHP_EOL
        . 'Header set Pragma "no-cache"' . PHP_EOL
        . 'Header set Expires 0' . PHP_EOL
        . '</IfModule>' . PHP_EOL;
    }

    $htaccess_data .= '<Files *.conf>' . PHP_EOL;
    $htaccess_data .= '  Order Deny,Allow' . PHP_EOL;
    $htaccess_data .= '  Deny from all' . PHP_EOL;
    $htaccess_data .= '</Files>' . PHP_EOL;
    $htaccess_data .= '<Files *.log>' . PHP_EOL;
    $htaccess_data .= '  Order Deny,Allow' . PHP_EOL;
    $htaccess_data .= '  Deny from all' . PHP_EOL;
    $htaccess_data .= '</Files>' . PHP_EOL;
    $htaccess_data .= '<Files *.ini>' . PHP_EOL;
    $htaccess_data .= '  Order Deny,Allow' . PHP_EOL;
    $htaccess_data .= '  Deny from all' . PHP_EOL;
    $htaccess_data .= '</Files>' . PHP_EOL;

    //
    // KATEGORIE — sitemap + pp_routes (bez zmian)
    //
    $results = $mdb->select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
    if ( is_array( $results ) ) foreach ( $results as $row )
    {
      !$row['start'] ? $language_link = $row['id'] . '/' : $language_link = '';

      $results2 = $mdb->select( 'pp_shop_categories_langs', [ '[><]pp_shop_categories' => [ 'category_id' => 'id' ] ], [ 'seo_link', 'title', 'category_id' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'o' => 'ASC' ] ] );
      if ( is_array( $results2 ) ) foreach ( $results2 as $row2 )
      {
        if ( $row2['title'] )
        {
          $site_map .= '<url>' . PHP_EOL;
          if ( $row2['seo_link'] )
            $site_map .= '<loc>https://' . $url . '/' . $language_link . self::seo( $row2['seo_link'] ) . '</loc>' . PHP_EOL;
          else
            $site_map .= '<loc>https://' . $url . '/' . $language_link . 'k-' . $row2['category_id'] . '-' . self::seo( $row2['title'] ) . '</loc>' . PHP_EOL;
          $site_map .= '<lastmod>' . date( 'Y-m-d' ) . '</lastmod>' . PHP_EOL;
          $site_map .= '<changefreq>daily</changefreq>' . PHP_EOL;
          $site_map .= '<priority>1</priority>' . PHP_EOL;
          $site_map .= '</url>' . PHP_EOL;

          $seoSlug = $row2['seo_link'] ? self::seo( $row2['seo_link'] ) : 'k-' . $row2['category_id'] . '-' . self::seo( $row2['title'] );

          $mdb->delete( 'pp_routes', [ 'AND' => [ 'category_id' => $row2['category_id'], 'lang_id' => $row['id'] ] ] );

          $mdb->insert( 'pp_routes', [
            'category_id' => $row2['category_id'],
            'lang_id'     => $row['id'],
            'pattern'     => '^' . $language_link . $seoSlug . '$',
            'destination' => 'index.php?category=' . $row2['category_id'] . '&lang=' . $row['id'],
          ] );
          $mdb->insert( 'pp_routes', [
            'category_id' => $row2['category_id'],
            'lang_id'     => $row['id'],
            'pattern'     => '^' . $language_link . $seoSlug . '/([0-9]+)$',
            'destination' => 'index.php?category=' . $row2['category_id'] . '&lang=' . $row['id'] . '&bs=$1',
          ] );
        }
      }
    }

    //
    // PRODUKTY — sitemap + pp_routes (bez zmian)
    //
    $results = $mdb->select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
    if ( is_array( $results ) )
    {
      foreach ( $results as $row )
      {
        !$row['start'] ? $language_link = $row['id'] . '/' : $language_link = '';

        $results2 = $mdb->select( 'pp_shop_products_langs', [ '[><]pp_shop_products' => [ 'product_id' => 'id' ] ], [ 'seo_link', 'name', 'product_id' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'name' => 'ASC' ] ] );
        if ( is_array( $results2 ) )
        {
          foreach ( $results2 as $row2 )
          {
            $mdb->delete( 'pp_routes', [ 'AND' => [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'] ] ] );

            if ( $row2['name'] )
            {
              $site_map .= '<url>' . PHP_EOL;
              if ( $row2['seo_link'] )
                $site_map .= '<loc>https://' . $url . '/' . $language_link . self::seo( $row2['seo_link'] ) . '</loc>' . PHP_EOL;
              else
                $site_map .= '<loc>https://' . $url . '/' . $language_link . 'p-' . $row2['product_id'] . '-' . self::seo( $row2['name'] ) . '</loc>' . PHP_EOL;
              $site_map .= '<lastmod>' . date( 'Y-m-d' ) . '</lastmod>' . PHP_EOL;
              $site_map .= '<changefreq>daily</changefreq>' . PHP_EOL;
              $site_map .= '<priority>1</priority>' . PHP_EOL;
              $site_map .= '</url>' . PHP_EOL;

              if ( $row2['seo_link'] )
              {
                $mdb->insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => '^' . $language_link . self::seo( $row2['seo_link'] ) . '$', 'destination' => 'index.php?product=' . $row2['product_id'] ] );
                $mdb->insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => '^' . $language_link . self::seo( $row2['seo_link'] ) . '/([0-9-]+)$', 'destination' => 'index.php?product=' . $row2['product_id'] . '&permutation_hash=$1' ] );
              }
              else
              {
                $mdb->insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => '^' . $language_link . 'p-' . $row2['product_id'] . '-' . self::seo( $row2['name'] ) . '$', 'destination' => 'index.php?product=' . $row2['product_id'] ] );
                $mdb->insert( 'pp_routes', [ 'product_id' => $row2['product_id'], 'lang_id' => $row['id'], 'pattern' => '^' . $language_link . 'p-' . $row2['product_id'] . '-' . self::seo( $row2['name'] ) . '/([0-9-]+)$', 'destination' => 'index.php?product=' . $row2['product_id'] . '&permutation_hash=$1' ] );
              }
            }
          }
        }
      }
    }

    //
    // STRONY + ARTYKULY — sitemap + pp_routes (bez zmian)
    //
    $results = $mdb->select( 'pp_langs', [ 'id', 'start' ], [ 'status' => 1, 'ORDER' => [ 'o' => 'ASC' ] ] );
    if ( is_array( $results ) )
      foreach ( $results as $row )
      {
        ( !$row['start'] and count( $results ) > 1 ) ? $language_link = $row['id'] . '/' : $language_link = '';

        $results2 = $mdb->select( 'pp_pages_langs', [ '[><]pp_pages' => [ 'page_id' => 'id' ] ], [ 'seo_link', 'title', 'page_id', 'noindex', 'start', 'link', 'page_type' ], [ 'lang_id' => $row['id'], 'ORDER' => [ 'start' => 'DESC', 'o' => 'ASC' ] ] );
        if ( is_array( $results2 ) )
          foreach ( $results2 as $row2 )
          {
            if ( $row2['title'] and $row2['page_type'] != 3 and $row2['page_type'] != 5 )
            {
              if ( !$row2['noindex'] )
              {
                $site_map .= '<url>' . PHP_EOL;
                if ( $row2['seo_link'] )
                  $site_map .= '<loc>https://' . $url . '/' . self::seo( $row2['seo_link'] ) . '</loc>' . PHP_EOL;
                else
                  $site_map .= '<loc>https://' . $url . '/s-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '</loc>' . PHP_EOL;
                $site_map .= '<lastmod>' . date( 'Y-m-d' ) . '</lastmod>' . PHP_EOL;
                $site_map .= '<changefreq>daily</changefreq>' . PHP_EOL;
                $site_map .= '<priority>1</priority>' . PHP_EOL;
                $site_map .= '</url>' . PHP_EOL;
              }
              else if ( $row2['noindex'] and $row2['seo_link'] )
              {
                $robots .= 'User-agent: GoogleBot' . PHP_EOL;
                $robots .= 'Disallow: /' . $row2['seo_link'] . PHP_EOL;
              }

              if ( $row2['start'] )
              {
                if ( $row2['seo_link'] )
                {
                  $htaccess_data .= PHP_EOL . 'RewriteCond %{REQUEST_URI} ^/' . self::seo( $row2['seo_link'] ) . '$';
                  $htaccess_data .= PHP_EOL . 'RewriteRule ^(.*)$ http://www.' . $url . '/' . $language_link . ' [R=permanent,L]';
                  $htaccess_data .= PHP_EOL . 'RewriteCond %{REQUEST_URI} ^/' . self::seo( $row2['seo_link'] ) . '-1$';
                  $htaccess_data .= PHP_EOL . 'RewriteRule ^(.*)$ http://www.' . $url . '/' . $language_link . ' [R=permanent,L]';
                }
                else
                {
                  $htaccess_data .= PHP_EOL . 'RewriteCond %{REQUEST_URI} ^/s-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '$';
                  $htaccess_data .= PHP_EOL . 'RewriteRule ^(.*)$ http://www.' . $url . '/' . $language_link . ' [R=permanent,L]';
                  $htaccess_data .= PHP_EOL . 'RewriteCond %{REQUEST_URI} ^/s-' . $row2['page_id'] . '-' . self::seo( $row2['title'] ) . '-1$';
                  $htaccess_data .= PHP_EOL . 'RewriteRule ^(.*)$ http://www.' . $url . '/' . $language_link . ' [R=permanent,L]';
                }
                $htaccess_data .= PHP_EOL . 'RewriteRule ^$ index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'] . ' [L]';
              }

              $seoSlug = $row2['seo_link'] ? self::seo( $row2['seo_link'] ) : 's-' . $row2['page_id'] . '-' . self::seo( $row2['title'] );
              $langPrefix = $row2['start'] ? '' : $language_link;

              $mdb->delete( 'pp_routes', [ 'AND' => [ 'page_id' => $row2['page_id'], 'lang_id' => $row['id'] ] ] );

              $mdb->insert( 'pp_routes', [
                'page_id'     => $row2['page_id'],
                'lang_id'     => $row['id'],
                'pattern'     => '^' . $langPrefix . $seoSlug . '$',
                'destination' => 'index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'],
              ] );
              $mdb->insert( 'pp_routes', [
                'page_id'     => $row2['page_id'],
                'lang_id'     => $row['id'],
                'pattern'     => '^' . $langPrefix . $seoSlug . '/([0-9]+)$',
                'destination' => 'index.php?a=page&id=' . $row2['page_id'] . '&lang=' . $row['id'] . '&bs=$1',
              ] );
            }
          }

        $results2 = $mdb->select( 'pp_articles_langs', [ '[><]pp_articles' => [ 'article_id' => 'id' ] ], [ 'seo_link', 'title', 'article_id', 'noindex', 'copy_from' ], [ 'AND' => [ 'status' => 1, 'lang_id' => $row['id'], 'block_direct_access' => 0 ] ] );
        if ( is_array( $results2 ) )
          foreach ( $results2 as $row2 )
          {
            if ( $row2['copy_from'] != null )
            {
              $results_tmp   = $mdb->get( 'pp_articles_langs', [ 'seo_link', 'title' ], [ 'AND' => [ 'article_id' => $row2['article_id'], 'lang_id' => $row2['copy_from'] ] ] );
              $row2['seo_link'] = $results_tmp['seo_link'];
              $row2['title']    = $results_tmp['title'];
            }

            if ( !$row2['noindex'] )
            {
              $site_map .= '<url>' . PHP_EOL;
              if ( $row2['seo_link'] )
                $site_map .= '<loc>https://' . $url . '/' . self::seo( $row2['seo_link'] ) . '</loc>' . PHP_EOL;
              else
                $site_map .= '<loc>https://' . $url . '/a-' . $row2['article_id'] . '-' . self::seo( $row2['title'] ) . '</loc>' . PHP_EOL;
              $site_map .= '<lastmod>' . date( 'Y-m-d' ) . '</lastmod>' . PHP_EOL;
              $site_map .= '<changefreq>daily</changefreq>' . PHP_EOL;
              $site_map .= '<priority>1</priority>' . PHP_EOL;
              $site_map .= '</url>' . PHP_EOL;
            }
            else if ( $row2['noindex'] and $row2['seo_link'] )
            {
              $robots .= 'User-agent: GoogleBot' . PHP_EOL;
              $robots .= 'Disallow: /' . $row2['seo_link'] . PHP_EOL;
            }

            $mdb->delete( 'pp_routes', [ 'AND' => [ 'article_id' => $row2['article_id'], 'lang_id' => $row['id'] ] ] );

            if ( $row2['seo_link'] )
            {
              $mdb->insert( 'pp_routes', [
                'article_id'  => $row2['article_id'],
                'lang_id'     => $row['id'],
                'pattern'     => '^' . $language_link . self::seo( $row2['seo_link'] ) . '$',
                'destination' => 'index.php?article=' . $row2['article_id'] . '&lang=' . $row['id'],
              ] );
            }
            else if ( $row2['title'] != null )
            {
              $mdb->insert( 'pp_routes', [
                'article_id'  => $row2['article_id'],
                'lang_id'     => $row['id'],
                'pattern'     => '^' . $language_link . 'a-' . $row2['article_id'] . '-' . self::seo( $row2['title'] ) . '$',
                'destination' => 'index.php?article=' . $row2['article_id'] . '&lang=' . $row['id'],
              ] );
            }
          }
      }

    // Invalidacja cache tras
    try {
      ( new \Shared\Cache\CacheHandler() )->delete( 'pp_routes:all' );
    } catch ( \Exception $e ) {
      // Redis niedostepny — ignorujemy
    }

    $results = $mdb->get( 'pp_settings', 'value', [ 'param' => 'htaccess' ] );
    if ( $results )
      $htaccess_data .= PHP_EOL . $results;

    $results = $mdb->get( 'pp_settings', 'value', [ 'param' => 'robots' ] );
    if ( $results )
      $robots .= PHP_EOL . $results;

    $site_map .= '</urlset>';

    $htaccess_data .= PHP_EOL;
    $htaccess_data .= 'RewriteCond %{REQUEST_FILENAME} !-f' . PHP_EOL;
    $htaccess_data .= 'RewriteCond %{REQUEST_FILENAME} !-d' . PHP_EOL;
    $htaccess_data .= 'RewriteRule ^ index.php [L]';

    // Niektore hostingi blokuja zmiane wersji PHP przez .htaccess.
    $htaccess_data = preg_replace( '/^(\\s*)(AddHandler|SetHandler|ForceType)\\b/im', '$1# $2', $htaccess_data );

    $fp = fopen( $dir . '.htaccess', 'w' );
    fwrite( $fp, $htaccess_data );
    fclose( $fp );

    $fp = fopen( $dir . 'sitemap.xml', 'w' );
    fwrite( $fp, $site_map );
    fclose( $fp );

    $fp = fopen( $dir . 'robots.txt', 'w' );
    fwrite( $fp, $robots );
    fclose( $fp );
  }
```

**Step 2: Run tests**
```
php phpunit.phar --configuration phpunit.xml
```
Expected: all tests pass (htacces() has no unit tests, covered by integration).

---

## Task 3: Delete `libraries/htaccess.conf`

**Files:**
- Delete: `libraries/htaccess.conf`

**Step 1: Verify htacces() no longer references the file**

Search for any remaining `file_get_contents` referencing `htaccess.conf`:
```bash
grep -r "htaccess.conf" autoload/
```
Expected: no results.

**Step 2: Delete the file**
```bash
rm libraries/htaccess.conf
```

**Step 3: Run tests**
```
php phpunit.phar --configuration phpunit.xml
```
Expected: all tests still pass.

---

## Task 4: Update `docs/DATABASE_STRUCTURE.md`

**Files:**
- Modify: `docs/DATABASE_STRUCTURE.md` (section `## pp_routes`)

**Step 1: Add `type` column to the pp_routes table description**

Find the `## pp_routes` section and add the `type` row to the column table:

```markdown
| type | Typ trasy: NULL = encja (produkt/kategoria/strona/artykuł), 'system' = trasa systemowa |
```

Also update the description paragraph to mention that system routes are managed automatically.

---

## Task 5: Manual integration test on server

**Step 1: Apply migration**
```sql
ALTER TABLE pp_routes ADD COLUMN type VARCHAR(20) NULL AFTER article_id;
```

**Step 2: Trigger `htacces()` regeneration**

Log in to admin panel → save any product or category → this calls `htacces()`.

**Step 3: Verify pp_routes has system routes**
```sql
SELECT COUNT(*) FROM pp_routes WHERE type = 'system';
```
Expected: ~35+ rows (32 static + language rows + producer rows).

**Step 4: Verify .htaccess was generated correctly**

Open `.htaccess` — should NOT contain `RewriteRule ^koszyk$`, `^logowanie$`, etc. Should contain HTTPS redirect, admin routing, thumb routing, cache block.

**Step 5: Test URLs in browser**
- `/koszyk` → koszyk page ✓
- `/logowanie` → login page ✓
- `/wyszukiwarka/test` → search results ✓
- `/zamowienie/abc123` → order details ✓
- `/shopClient/confirm/hash=xyz` → client confirm action ✓
- Category URL → category page ✓
- Product URL → product page ✓

**Step 6: Run full test suite**
```
php phpunit.phar --configuration phpunit.xml
```
Expected: 807 tests, all pass.

---

## Task 6: Commit

**Step 1: Stage and commit**
```bash
git add migrations/0.329.sql
git add autoload/Shared/Helpers/Helpers.php
git add docs/DATABASE_STRUCTURE.md
git add docs/plans/2026-02-27-htaccess-conf-elimination.md
git add docs/plans/2026-02-27-htaccess-to-routes-design.md
git rm libraries/htaccess.conf
git commit -m "feat: eliminate htaccess.conf, move all routes to pp_routes (v0.330)"
```
