| name | franchise-site-parity |
| description | Bring a franchise partner storefront (Ghana .com.gh, Malaysia .com.my, or a new country) to full parity with Singapore for NON-WSQ courses — import courses, mirror the category tree + mega-menu, sync product↔category assignments, category banners/descriptions, currency switcher, remove SG-only funding text, fix fonts/banners, WhatsApp, contact/footer/trainer info, and the blog. Use when asked to "make GH same as Singapore", "port courses to the partner site", "the <country> categories/menu/blog/footer are different from SG", "add currency switcher / WhatsApp to <country>", "remove funding on <country>", or when onboarding/refreshing a franchisee site. Everything here is PARTNER-DB-ONLY (never a repo/code change, never touch SG). |
Franchise Site Parity (mirror Singapore, non-WSQ)
Playbook for making a franchise partner site (GH/MY/new country) match Singapore's
non-WSQ storefront. Distilled from the Ghana build-out. Pair with
[[add-country-store]] (which wires the domain → store view) — this skill handles the
CONTENT parity that comes after.
HARD RULES (read first — violating these breaks production)
- Partner changes are DB-ONLY on the partner's OWN server. NEVER a repo/code change.
A
git push deploys to SG too. Everything below is SQL / config / media on the
partner DB. See memory feedback_gh_changes_never_touch_sg_hard_rule.
- Never SSH-write to SG. Read SG only from the local backup DB (docker
courses_backupDB) or public HTTP (curl a rendered page). Both are read-only and
don't touch SG prod.
- Match products by SKU, categories by url_key. Partner DBs are clones of SG so
entity_ids DIFFER but SKUs and url_keys are stable join keys. url_keys are globally
unique here (flat URLs — see
FlatCategoryUrl).
- WSQ / IBF / SkillsFuture are Singapore-only. Partners have only the non-WSQ
C-prefix courses. Never create WSQ (TGS-) courses or WSQ/IBF categories on a partner.
- Flat catalog is ON → after ANY category/product/menu write you MUST reindex
catalog_category_flat (and catalog_category_product for assignments) and flush the
Redis cache. Run the reindex as a SEPARATE step (it can exceed 2 min and time out).
Access pattern (from repo .env)
export SSHPASS='<partner-root-pw>'
GH="root@<partner-ip>"
DB=<mysql-container>
WEB=<web-container>
sshpass -e ssh -o StrictHostKeyChecking=no -o ConnectTimeout=30 $GH \
"docker exec -i $DB sh -c 'mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" <dbname>'" < file.sql
sshpass -e ssh ... $GH "docker exec $WEB php -r '
require \"/var/www/html/app/Mage.php\"; Mage::app();
foreach([\"catalog_category_flat\",\"catalog_category_product\",\"catalog_url\"] as \$c){
\$p=Mage::getModel(\"index/indexer\")->getProcessByCode(\$c); if(\$p){\$p->reindexEverything();}}
Mage::app()->getCacheInstance()->flush();'"
docker exec ai-mms-db_mysql-1 sh -c "mysql -umagento -pmagento123 courses_backupDB -N -e \"<SELECT...>\""
Transient Permission denied (publickey,password) = SSH flake; just retry.
EAV attribute ids (category, entity_type_id=3; confirm per DB, usually stable)
name=41 (or 41), url_key=43, description=44 (text table), image=45,
include_in_menu, is_anchor, display_mode; Infortis mega: umm_dd_type=185 (varchar),
umm_dd_width=186 (varchar), umm_dd_proportions=187 (varchar), umm_dd_columns=188 (int).
Always resolve ids per-DB: SELECT attribute_id FROM eav_attribute WHERE attribute_code=? AND entity_type_id=3.
1. Course catalog — import all non-WSQ (C-prefix) SG courses
The partner should hold every ENABLED SG C-prefix course. Usually already ~complete
(GH had 494/498; the 4 absent were status=2 disabled/retired — skip those).
comm -23 <(grep '^C' sg_skus.txt|sort -u) <(sort -u partner_skus.txt)
Porting a full product (rare) means recreating catalog_product_entity + all EAV + website
link + stock + custom options — a big per-product job; do it via the catalog/product model
in a PHP script, matched by SKU. Confirm the partner catalog is website-linked
(catalog_product_website) and status=1.
2. Category tree — same as Singapore
The 4 non-WSQ menu roots (SG ids → find partner ids by url_key):
adult-training-courses, software-training-courses, certification-exam-prep-courses,
specialisation-courses (Bootcamp — usually no subcats). See memory
feedback_ultimo_megamenu_match_sg for the full method. Do ALL of a–d for ALL 4 roots
(not just Adult — the classic bug is doing only Adult and leaving Software/Cert Prep empty):
a) Create missing categories (diff SG subtree vs partner by url_key). Use a PHP script
IN the web container via the catalog/category MODEL (not raw SQL — the model builds
path/url_rewrite/indexes):
$c=Mage::getModel('catalog/category'); $c->setStoreId(0);
$c->addData(['name'=>$n,'url_key'=>$uk,'is_active'=>1,'include_in_menu'=>1,'is_anchor'=>0,
'display_mode'=>'PRODUCTS','position'=>$p, 'image'=>$img?:null,'description'=>$desc?:null]);
$parent=;
$c->setPath($parent->getPath()); $c->setParentId($parent->getId());
$c->setLevel($parent->getLevel()+1); $c->save();
b) include_in_menu — enable on every descendant of the 4 roots (SG has all in-menu):
SET @im:=(SELECT attribute_id FROM eav_attribute WHERE attribute_code='include_in_menu' AND entity_type_id=3);
INSERT INTO catalog_category_entity_int (entity_type_id,attribute_id,store_id,entity_id,value)
SELECT 3,@im,0,e.entity_id,1 FROM catalog_category_entity e
WHERE e.path LIKE '1/2/<adultId>/%' OR e.path LIKE '1/2/<softwareId>/%'
OR e.path LIKE '1/2/<certprepId>/%' OR e.path LIKE '1/2/<bootcampId>/%'
ON DUPLICATE KEY UPDATE value=1;
c) names + positions — the raw partner names ("Comptia Certification Exam Prep Courses")
differ from SG's clean nav labels ("CompTIA Certification Exam Prep"). SG uses the category
name itself as the label. Dump SG (url_key,position,name) for the 4-root subtree; for each,
UPDATE catalog_category_entity SET position=? and
UPDATE catalog_category_entity_varchar SET value='<name>' WHERE attribute_id=<name> AND store_id=0.
url_key/URLs stay untouched (no SEO churn). Double apostrophes for SQL.
d) mega-menu layout (umm_dd_*) — mega renders at ANY depth (e.g. Artificial
Intelligence is level-4 but opens its own mega sub-panel). Clear+set all four umm attrs for
EVERY 4-root category by url_key from SG. e.g. AI = dd_type=1, cols=3, width=800px, prop=4;4;4;; the roots are dd_type=1 with 5/4/3 cols. Verify: the <li> gains class
mega, style="width:…px", dd-itemgrid-Ncol.
3. Product ↔ category assignments (same courses in same categories)
Match products by SKU, categories by url_key. WSQ/IBF cats don't exist on the partner →
auto-skipped. Deleting a category assignment NEVER deletes the course.
Dump SG catalog_category_product as (sku,url_key,position) for shared products & partner-existing cats.
Diff vs partner current. ADD missing + DELETE extra by (category_id,product_id) resolved via
partner sku→id / url_key→id maps. Reindex catalog_category_product + catalog_category_flat.
Watch for a partner category over-stuffed vs SG (GH had 305 in AI vs SG's 69 — 236 wrong).
Verify against SG's LIVE page before deleting, then confirm partner product COUNT is unchanged.
4. Category banners + descriptions (from shared R2)
Image files are already on the SHARED R2 bucket (SG uses them); nothing to upload — just
copy the image(45, varchar) + description(44, text table) attrs SG→partner by url_key.
Dump SG: url_key, image, REPLACE(REPLACE(TO_BASE64(description),'\n',''),'\r','') -- STRIP TO_BASE64 wrapping!
INSERT ... ON DUPLICATE KEY UPDATE into catalog_category_entity_varchar(45) / _text(44) at store_id=0.
Partner renders /media/catalog/category/<file> → the partner's media/.htaccess
302-redirects missing catalog/category/* to R2 (final 200). Verify a sample loads. See
memory feedback_category_banners_on_r2.
5. Currency switcher (local currency + USD)
Allowed currencies are usually already set (currency/options/allow=GHS,USD,
base/default = local). The switcher is hidden because there's no exchange rate:
INSERT INTO directory_currency_rate (currency_from,currency_to,rate) VALUES
('<LOCAL>','<LOCAL>',1),('<LOCAL>','USD',<usd_per_local>),('USD','USD',1),('USD','<LOCAL>',<local_per_usd>)
ON DUPLICATE KEY UPDATE rate=VALUES(rate);
Use an approximate rate and tell the admin to refresh to the live rate. Verify the homepage
HTML has a currency-switcher with both options.
6. Remove all SG-only funding (WSQ/IBF/SkillsFuture) from the partner
Funding text is Singapore-only and misleads partner users. Three surfaces, three techniques:
- Course page WSQ/funding policy cards: hidden per-website via CSS already in some sites
(
.course-policy-card--wsq{display:none} in design/head/includes). Add website-scope CSS
in core_config_data['design/head/includes'] to hide any funding card/tile classes.
- Blog CTA ("WSQ up to 70% SkillsFuture") is HARDCODED in shared templates
(
mmd/blog/view.phtml, list.phtml) → hide GH-only with head-includes CSS:
.mmd-blog-cta > p{display:none!important}.mmd-blog-sub{display:none!important}.
- Text that flows through
$this->__() (e.g. WhatsApp popup "Course Funding" /
"SFC, WSQ, IBF, SFEC, UTAP, PSEA") → override PER-STORE via core_translate (no CSS, real
text swap):
INSERT INTO core_translate (store_id,locale,string,translate,crc_string) VALUES
(<storeId>,'en_US','Course Funding','Course Info',CRC32('Course Funding')) ...
ON DUPLICATE KEY UPDATE translate=VALUES(translate);
Match the __() string EXACTLY; the load matches by string; flush cache.
CRITICAL — use the STORE's locale, not en_US. core_translate rows must match the
store's actual locale. GH = en_US but MY = en_GB (SELECT value FROM core_config_data WHERE path='general/locale/code' AND scope='stores' AND scope_id=<id>). An en_US row on an
en_GB store silently does nothing (real MY miss).
- REMOVE vs REPLACE depends on the partner's funding scheme. GH has NO funding → hide the
WSQ card + blog CTA (above). MY uses HRD Corp → still hide the SG WSQ funding card
(
.course-policy-card--wsq) but KEEP the partner's HRD Corp content (MY already had a
"Grants and Subsidies" footer section + "Steps to Submit HRD Corp Grant"; MMD_SG_PRICING
is already active:false). So hide the SG-specific funding card, leave local grant content.
6b. Top nav — trim partner-specific roots + sync the 4 keeper roots
A partner may carry extra top-level (level-2) roots SG/GH don't show — e.g. MY had
latest-courses ("Regional Franchisee", id 180) and wsq-ibf-skillsfuture-utap-funded-courses
("WSQ IBF SkillsFuture UTAP Funded Courses", id 181). Hide them: set include_in_menu=0
(catalog_category_entity_int, the store-0 row). The 4 keeper roots' own names/positions are
NOT touched by the descendant name-sync (roots aren't under 1/2/N/%), so set them explicitly
to match GH: Adult Courses(pos1)/CERT Prep Courses(2)/Software Training(3)/Bootcamp(4). Reindex
catalog_category_flat. GH & MY share the same root entity_ids (3/6/36/64/180/181 — clones).
6c. Trainers — swap to the partner's local trainer
The "About the Trainer"/Trainers tab reads the per-product trainerprofile attribute
(a TEXT attr; find its id: attribute_code='trainerprofile'). The tab splits the blob on
<strong>Name:</strong> boundaries and resolves each bio (from the courses_trainers table by
title match, else the blob text). To attribute all partner courses to one local trainer (MY =
"Saeid", like GH's Dr Siraj), set that attr on every product at store 0:
INSERT INTO catalog_product_entity_text (entity_type_id,attribute_id,store_id,entity_id,value) SELECT 4,<tpAttr>,0,e.entity_id,'<p dir="ltr"><strong>Saeid:</strong> …bio…</p>' FROM catalog_product_entity e ON DUPLICATE KEY UPDATE value=VALUES(value); then reindex
catalog_product_flat + flush. GOTCHA: the trainer's photo may 500/404 everywhere (no original
on disk) — use a text-only bio or source/upload the image first. Get the bio from the partner's
HRD/trainer page if present. The trainer often already exists as an admin_user.
See memory feedback_gh_whatsapp_currency_homepage_config.
7. Font — no serif-flash, matches SG (Oswald)
Ultimo's google-font group emits font-family:"Oswald", georgia, serif and loads Oswald from
fonts.googleapis.com (flaky in-country → serif flash). Fix DB-only + partner media volume:
serve Oswald same-origin from /var/www/html/media/fonts/ (persistent volume) OR inline
it as a base64 data URI in design/head/includes (zero flash, no CORS). Set font group
custom + stack "Oswald", Arial, …, sans-serif. core_config_data.value is TEXT (65 KB) —
inline only the latin subset. Full detail: memory feedback_ultimo_google_font_serif_fallback.
8. Homepage banners + carousel (from R2)
Partner carousel slides are cms_blocks (GH = 58/59/102/103). Compress banners (Pillow;
convert/magick not on Mac), rclone to R2 wysiwyg/, point the cms_block <img src> at
the direct pub-*.r2.dev URL (not /media/wysiwyg, which isn't baked on the partner).
Homepage course rails = cms_page (GH home = page 34) product_list_featured widgets — swap
category_id+block_name together to re-theme a rail. Memory
feedback_partner_banners_via_direct_r2_url.
CAROUSEL SLIDE COUNT/ORDER = the ultraslideshow/general/blocks config, NOT is_active.
Infortis UltraSlideshow renders one slide per CMS-block identifier in the comma-separated
ultraslideshow/general/blocks config (per website AND store scope). MY had
block_slide4_malaysia,block_slide1_malaysia,block_slide2_malaysia,block_slide3_malaysia — 4
slides, and because block_slide4_malaysia (disabled) was listed FIRST it rendered an EMPTY
leading slide. Toggling a block's is_active does NOT remove its slot (a disabled block just
renders empty). To show exactly N slides in a given order, set the config to those N block ids:
UPDATE core_config_data SET value='block_slide1_malaysia,block_slide2_malaysia,block_slide3_malaysia' WHERE path='ultraslideshow/general/blocks' AND scope IN ('stores','websites') AND scope_id=<id>;
then flush. The rendered .the-slideshow is loaded lazily so grepping the raw homepage HTML
for slide <li>s is unreliable — trust the config + a banner-img count.
Full-block cms rewrites re-introduce literal \n/\t. mysql -N dumps a cms_block/page's
real newlines as the 2-char \n; if you rebuild the whole value and UPDATE it, those become
literal \n/\t that render as visible text (real MY footer bug). Prefer surgical REPLACE()
over full-content rewrites; if you must rewrite, REPLACE(REPLACE(content,'\\n','\n'),'\\t','\t')
afterward.
9. WhatsApp (floating widget + header + footer)
MMD_Whatsapp is config-driven: set mmd_whatsapp/general/enabled=1 + .../number at the
partner WEBSITE scope. Header WhatsApp = cms_block block_header_top_left; footer = the
contacts block (page) AND block_footer_row2_column5/footer blocks. Use the SG blue
icon #1e3a8a (not WhatsApp green) to match SG. wa.me/<digits> links.
10. Contact info, Our Websites, footer links, trainer info
- Contact "Contact Us Information" lives in BOTH the footer block and the
/contacts page
block — update both.
- Footer link columns + ECOBANK/payment + "Our Websites" live in the partner's
block_footer_row2_column5 (one big store-scoped block). Remove partner-inappropriate links
(Clientele, Regional Franchising, Affiliate) there. GOTCHA: a payment image src may point
at .com.sg (500) while the file exists on the partner domain — swap host to the partner's.
- "Our Websites" → copy SG's network list from SG's footer block by reading the SG backup.
- Trainer info: trainer profiles are keyed by SKU (
trainerprofile/dashboard blobs). If a
partner lost/needs them, restore/copy by SKU from the SG backup — see memory
reference_sg_server_access (253 WSQ trainerprofiles restored by SKU).
11. Blog — partner-relevant, funnels to PARTNER courses
mmd_blog_post table. Partner blog is cloned SG → delete all, insert partner posts:
DELETE FROM mmd_blog_post_vote; mmd_blog_post_tag; mmd_blog_post; then INSERT. In-content
CTAs use {{store direct_url='<url_key>.html'}} (resolves to the partner domain; double the
single quotes inside the SQL literal). Never reference WSQ/SkillsFuture/Singapore. Hide the
hardcoded blog funding CTA via the head-includes CSS in §6.
12. Schema migrations (when a change belongs in the repo, e.g. an SG/all-site change)
Most partner work is DB-only. But if a change is genuinely shared (SG + all partners), it
goes through the repo migration runner — and that runner is a production tripwire:
- Drop numbered
migrations/NNN-foo.sql; docker/entrypoint.sh runs migrations/apply.php
on deploy, tracked in schema_migrations. Dry-run the REAL apply.php, never the mysql
client (docker exec <web> php /var/www/html/migrations/apply.php). apply.php's PDO is
charset=utf8 and ABORTS the whole chain on the first failed statement → container exits →
every host 502s. The mysql client connects latin1 and hides the bug.
- UTF-8: any INSERT pulling from legacy tables (catalogsearch_query, old EAV) MUST be
UTF-8-sanitised or apply.php dies
1366 Incorrect string value '\x96…'. Cheapest filter for
ASCII: WHERE LENGTH(col)=CHAR_LENGTH(col). QUOTE() does NOT fix it.
- Country-instance drift: partner DBs LACK some SG-only tables (e.g.
smtppro_email_log).
Guard every table-touching stmt with information_schema existence check + PREPARE/DO 0,
FOREIGN_KEY_CHECKS=0. A bare DELETE 502'd GH/MY/NG once. After a migration push, check
.com.my/.gh too — not just SG.
- Idempotent always:
INSERT IGNORE / ON DUPLICATE KEY UPDATE; apply.php splits on
;-at-end-of-line so multi-line string values must not end a line with ;.
- CMS-block
REPLACE() migrations: generate hex/base64 PROGRAMMATICALLY (never hand-copy
multi-KB hex); use UNHEX()/FROM_BASE64 to bypass MySQL backslash-escape ambiguity.
- Encrypted config columns need
Mage::helper('core')->encrypt() before save.
See memories feedback_migration_applyphp_utf8_outage, feedback_migration_country_instance_table_differences,
feedback_apply_php_sql_splitter, feedback_db_sync_via_migration, feedback_cms_block_hex_replace_generate_programmatically.
Deploy / ops gotchas (partner servers on Coolify)
- Partner sites auto-deploy on push to
main via each Coolify instance's GitHub App source.
- Claude CAN'T trigger/watch Coolify deploys (no webhook in repo) — recovery is human-in-dashboard.
- After a push, watch
/version.txt + poll the homepage until 200 (a failed migration = 502).
- No blocking work in
entrypoint.sh before exec apache2-foreground (stalls healthcheck → 502).
- Coolify exit-255 = SSH-mux reaper (
SSH_MUX_ENABLED=false); site 000 on 443 = Traefik
wrong-network (traefik.docker.network=${COOLIFY_RESOURCE_UUID} label). Memories
feedback_coolify_ssh_mux_deploy_255, feedback_coolify_traefik_network_pin,
feedback_entrypoint_blocking_step_502, feedback_no_cron_invocation_in_repo.
Bundled scripts (scripts/ — copy, set the vars at top, run)
| Script | What it does |
|---|
scripts/lib.sh | Connection helpers: gq/gsql (partner query/apply via stdin), reindex_flush, sgq (SG backup read), r2 (rclone to R2). Source it first. |
scripts/catalog_diff.sh | SKU diff (products) + url_key diff (categories) SG↔partner; missing/extra reports. |
scripts/gen_create_categories.py → create_categories.php | Generate + run the catalog/category model creation for categories SG has that the partner lacks. |
scripts/sync_names_positions.py | Emit UPDATEs to set partner category name+position = SG (by url_key). |
scripts/sync_inmenu_umm.sql | Enable include_in_menu on the 4-root subtree + copy umm_dd_* mega layout. |
scripts/sync_category_media.py | Sync image(45)+description(44 text) SG→partner by url_key (handles TO_BASE64 wrapping). |
scripts/sync_product_categories.py | Sync catalog_category_product (SKU×url_key): add missing + delete extra, count-safe. |
scripts/currency_rate.sql | Insert the local↔USD directory_currency_rate rows. |
scripts/remove_funding.sql | head-includes CSS (course + blog funding) + core_translate popup text swap. |
scripts/font_inline.py → font_inline.sql | Fetch Oswald woff2, inline as base64 data URI into design/head/includes. |
scripts/blog_generator.py | Build the DELETE-SG + INSERT-partner-posts SQL from a post list (funnels to partner courses). |
Read references/migrations.md and references/fonts.md for the deep dives.
Verification checklist (curl the partner prod, expect these)
- Homepage/menu 200; mega-menus show child items with SG labels; product count unchanged.
- Category pages 200 with banner image (final 200 after R2 redirect) + description.
- Currency switcher shows local + USD.
- 0 occurrences of
WSQ/SkillsFuture/.com.sg in partner course/blog visible text
(nav url_keys containing "skillsfuture" are harmless slugs).
- WhatsApp launcher + header + footer present; blue icon; popup says "Course Info".
- Blog lists partner posts only; each funnels to a partner course page.
- After every change: reindex
catalog_category_flat (+catalog_category_product) + flush.
Related memories (the source incidents, with exact SQL)
feedback_gh_changes_never_touch_sg_hard_rule, project_franchise_one_store_per_site,
feedback_ultimo_megamenu_match_sg, feedback_category_banners_on_r2,
feedback_partner_banners_via_direct_r2_url, feedback_ultimo_google_font_serif_fallback,
feedback_gh_whatsapp_currency_homepage_config, reference_gh_server_access,
reference_sg_server_access, feedback_flat_catalog_reindex, feedback_magento_cache_lives_in_redis.