forked from cartsnitch/cartsnitch
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75be08ccf3 | |||
| b43ec1fb9b | |||
| 129f0adc96 | |||
| ea789378dd | |||
| 2f096c985a | |||
| ad218c07ec | |||
| fff9f6f63a | |||
| b0ea4767b6 | |||
| 003c62da3e | |||
| d2337a7ef7 | |||
| 656c8d3842 |
@@ -48,6 +48,8 @@ jobs:
|
||||
build-and-push:
|
||||
runs-on: runners-cartsnitch
|
||||
needs: [lint, test]
|
||||
outputs:
|
||||
calver_tag: ${{ steps.calver.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -104,3 +106,41 @@ jobs:
|
||||
run: |
|
||||
git tag "v${{ steps.calver.outputs.version }}"
|
||||
git push origin "v${{ steps.calver.outputs.version }}"
|
||||
|
||||
deploy-dev:
|
||||
runs-on: runners-cartsnitch
|
||||
needs: [build-and-push]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CARTSNITCH_APP_ID }}
|
||||
private-key: ${{ secrets.CARTSNITCH_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: infra
|
||||
|
||||
- name: Checkout infra repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: cartsnitch/infra
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
ref: main
|
||||
|
||||
- name: Install kubectl
|
||||
uses: azure/setup-kubectl@v4
|
||||
|
||||
- name: Update dev overlay image tag
|
||||
working-directory: apps/overlays/dev
|
||||
run: |
|
||||
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }}
|
||||
|
||||
- name: Commit and push to infra
|
||||
run: |
|
||||
cd apps/overlays/dev
|
||||
git config user.name "cartsnitch-ci[bot]"
|
||||
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
|
||||
git add kustomization.yaml
|
||||
git commit -m "ci(dev): update cartsnitch image to ${{ needs.build-and-push.outputs.calver_tag }}"
|
||||
git push origin main
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Add Better-Auth tables and extend users table.
|
||||
|
||||
Creates sessions, accounts, and verifications tables for Better-Auth.
|
||||
Adds email_verified and image columns to existing users table.
|
||||
Migrates password hashes from users.hashed_password to accounts.password.
|
||||
|
||||
Revision ID: 002_better_auth_tables
|
||||
Revises: 001_encrypt_session_data
|
||||
Create Date: 2026-03-28
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "002_better_auth_tables"
|
||||
down_revision = "001_encrypt_session_data"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# --- Extend users table for Better-Auth compatibility ---
|
||||
op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"))
|
||||
op.add_column("users", sa.Column("image", sa.Text(), nullable=True))
|
||||
|
||||
# --- Create sessions table ---
|
||||
op.create_table(
|
||||
"sessions",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("token", sa.Text(), nullable=False),
|
||||
sa.Column("user_id", sa.Text(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("ip_address", sa.Text(), nullable=True),
|
||||
sa.Column("user_agent", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_sessions_token", "sessions", ["token"], unique=True)
|
||||
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
|
||||
|
||||
# --- Create accounts table ---
|
||||
op.create_table(
|
||||
"accounts",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("user_id", sa.Text(), nullable=False),
|
||||
sa.Column("account_id", sa.Text(), nullable=False),
|
||||
sa.Column("provider_id", sa.Text(), nullable=False),
|
||||
sa.Column("access_token", sa.Text(), nullable=True),
|
||||
sa.Column("refresh_token", sa.Text(), nullable=True),
|
||||
sa.Column("access_token_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("refresh_token_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("scope", sa.Text(), nullable=True),
|
||||
sa.Column("id_token", sa.Text(), nullable=True),
|
||||
sa.Column("password", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_accounts_user_id", "accounts", ["user_id"])
|
||||
|
||||
# --- Create verifications table ---
|
||||
op.create_table(
|
||||
"verifications",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("identifier", sa.Text(), nullable=False),
|
||||
sa.Column("value", sa.Text(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
# --- Migrate existing password hashes to accounts table ---
|
||||
# For each user with a hashed_password, create a 'credential' account row
|
||||
conn = op.get_bind()
|
||||
users = conn.execute(
|
||||
text("SELECT id, hashed_password FROM users WHERE hashed_password IS NOT NULL")
|
||||
).fetchall()
|
||||
|
||||
for user_id, hashed_password in users:
|
||||
user_id_str = str(user_id)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) "
|
||||
"VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())"
|
||||
),
|
||||
{"user_id": user_id_str, "account_id": user_id_str, "password": hashed_password},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("verifications")
|
||||
op.drop_table("accounts")
|
||||
op.drop_index("ix_sessions_user_id", table_name="sessions")
|
||||
op.drop_index("ix_sessions_token", table_name="sessions")
|
||||
op.drop_table("sessions")
|
||||
op.drop_column("users", "image")
|
||||
op.drop_column("users", "email_verified")
|
||||
@@ -0,0 +1,28 @@
|
||||
# CartSnitch Common
|
||||
|
||||
Shared models, schemas, and utilities for CartSnitch services.
|
||||
|
||||
## Test Users
|
||||
|
||||
The following users are seeded by `cartsnitch-seed` and can be used for local development and UAT.
|
||||
|
||||
| Email | Password | Display Name | Notes |
|
||||
|---|---|---|---|
|
||||
| `uat@cartsnitch.com` | `CartSnitch-UAT-2026!` | UAT Tester | Primary UAT account. Use for regression testing in the CartSnitch frontend. Created by the seed runner via Better-Auth's bcrypt path — credentials work against the live auth service. Idempotent; re-running the seed skips this user if it already exists. |
|
||||
|
||||
### Running the Seed
|
||||
|
||||
```bash
|
||||
# Install with seed dependencies
|
||||
pip install -e "cartsnitch-common[seed]"
|
||||
|
||||
# Run (requires CARTSNITCH_DATABASE_URL_SYNC)
|
||||
CARTSNITCH_DATABASE_URL_SYNC=postgresql://user:pass@localhost:5432/cartsnitch \
|
||||
cartsnitch-seed
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Models** live in `src/cartsnitch_common/models/`
|
||||
- **Alembic migrations** run via the `api` service (`api/alembic/`)
|
||||
- **Seed runner** runs via `cartsnitch-seed` (installed as a package entry point)
|
||||
@@ -27,6 +27,7 @@ dev = [
|
||||
]
|
||||
seed = [
|
||||
"faker>=33.0,<34.0",
|
||||
"bcrypt>=4.0,<6.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import bcrypt
|
||||
from faker import Faker
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -184,6 +186,65 @@ def run_seed(
|
||||
|
||||
session.commit()
|
||||
|
||||
_seed_uat_user(session)
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
_log("")
|
||||
_log(f"Seed complete in {elapsed:.1f}s")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UAT seed user
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
UAT_EMAIL = "uat@cartsnitch.com"
|
||||
UAT_PASSWORD = "CartSnitch-UAT-2026!"
|
||||
UAT_DISPLAY_NAME = "UAT Tester"
|
||||
UAT_USER_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
|
||||
|
||||
def _seed_uat_user(session: Session) -> None:
|
||||
"""Insert or verify the dedicated UAT test user.
|
||||
|
||||
The user is created via Better-Auth's bcrypt hashing path so credentials
|
||||
work against the live auth service. Idempotent — skips if the user already
|
||||
exists.
|
||||
"""
|
||||
existing = session.execute(
|
||||
text("SELECT id FROM users WHERE email = :email"),
|
||||
{"email": UAT_EMAIL},
|
||||
).fetchone()
|
||||
|
||||
if existing is not None:
|
||||
_log(f"UAT user {UAT_EMAIL} already exists — skipping")
|
||||
return
|
||||
|
||||
password_hash = bcrypt.hashpw(UAT_PASSWORD.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
session.execute(
|
||||
text(
|
||||
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) "
|
||||
"VALUES (:id, :email, :hashed_password, :display_name, true, now(), now())"
|
||||
),
|
||||
{
|
||||
"id": str(UAT_USER_ID),
|
||||
"email": UAT_EMAIL,
|
||||
"hashed_password": password_hash,
|
||||
"display_name": UAT_DISPLAY_NAME,
|
||||
},
|
||||
)
|
||||
|
||||
session.execute(
|
||||
text(
|
||||
"INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) "
|
||||
"VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())"
|
||||
),
|
||||
{
|
||||
"user_id": str(UAT_USER_ID),
|
||||
"account_id": str(UAT_USER_ID),
|
||||
"password": password_hash,
|
||||
},
|
||||
)
|
||||
|
||||
session.commit()
|
||||
_log(f"UAT user {UAT_EMAIL} created")
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
title: "Understanding Shrinkflation: A Consumer's FAQ"
|
||||
slug: shrinkflation-consumer-faq
|
||||
status: draft
|
||||
version: 1.0
|
||||
last_updated: 2026-03-22
|
||||
description: "Shrinkflation is how brands quietly raise prices by giving you less product for the same money. Here is what it is, why it is legal, and how to detect it."
|
||||
tags: ["shrinkflation", "consumer-faq", "grocery-prices", "price-transparency", "unit-price"]
|
||||
series: "The Shrinkflation Files"
|
||||
series_part: 0
|
||||
target_publish: 2026-04-01
|
||||
target_keywords: ["what is shrinkflation", "shrinkflation examples", "why did my product get smaller", "is shrinkflation legal"]
|
||||
---
|
||||
|
||||
# Understanding Shrinkflation: A Consumer's FAQ
|
||||
|
||||
You notice it at the grocery store: the cereal box looks smaller. The chip bag seems to have less air in it. The pasta salad you loved now fits less in the container. But the price is the same — or higher.
|
||||
|
||||
That is shrinkflation. Here is what you need to know.
|
||||
|
||||
---
|
||||
|
||||
## What Is Shrinkflation?
|
||||
|
||||
Shrinkflation is the practice of reducing the size or quantity of a product while keeping the price the same — or raising it. The per-unit cost increases without the packaging change being obvious at first glance.
|
||||
|
||||
It is different from inflation. Inflation raises prices for the same product. Shrinkflation keeps the price the same for a smaller product. Both cost you more per ounce, per gram, or per use.
|
||||
|
||||
---
|
||||
|
||||
## Is Shrinkflation Legal?
|
||||
|
||||
Yes. Shrinkflation is legal in the US and most markets. Manufacturers are required to state the net weight or count on the packaging, but they are not required to announce when a product gets smaller. There is no federal regulation specifically banning shrinkflation.
|
||||
|
||||
Some regulators have begun studying the practice, and there have been proposals for mandatory price-per-unit labeling at the shelf level, but no binding rules exist as of 2026.
|
||||
|
||||
---
|
||||
|
||||
## What's an Example of Shrinkflation?
|
||||
|
||||
Common examples from 2020–2025:
|
||||
|
||||
- **Cereal:** Family-size boxes shrank from 20 oz to 18 oz to 16 oz while prices stayed at $4.99–$5.99
|
||||
- **Crackers:** Standard sleeve count dropped from 4 to 3 packs while shelf price remained constant
|
||||
- **Yogurt:** Multipacks reduced from 6 oz cups to 5.3 oz cups
|
||||
- **Paper towels:** Roll count dropped from 12 to 10 while price stayed the same
|
||||
- **Dish soap:** Bottle volumes shrank from 24 oz to 20 oz
|
||||
|
||||
In every case, the per-unit cost increased even when the shelf price did not change — or changed less than the size reduction warranted.
|
||||
|
||||
---
|
||||
|
||||
## How Much Does Shrinkflation Cost the Average Family?
|
||||
|
||||
Estimates vary by shopping habits and product categories. CartSnitch analysis of manufacturer packaging data suggests the average US household spends an additional $80–$120 per year on cereals alone due to shrinkflation. Across all categories — snacks, dairy, household goods, beverages — total hidden costs per household are estimated at $300–$500 per year.
|
||||
|
||||
These figures are directional estimates based on publicly available manufacturer packaging data, not CartSnitch production data.
|
||||
|
||||
---
|
||||
|
||||
## Why Do Brands Use Shrinkflation?
|
||||
|
||||
Brands use shrinkflation because consumers notice price increases more than package size decreases. A $5 cereal box going to $5.50 is visible and may cause consumers to switch to competitors. A $5 cereal box shrinking from 18 oz to 15 oz at the same price is rarely noticed until someone like CartSnitch tracks the unit price.
|
||||
|
||||
Shrinkflation is most common in products where:
|
||||
- Brand loyalty is high (consumers repurchase without checking alternatives)
|
||||
- Unit prices are not prominently displayed
|
||||
- Size reductions are modest (5–15%)
|
||||
- The product is purchased regularly
|
||||
|
||||
---
|
||||
|
||||
## How Do I Detect Shrinkflation?
|
||||
|
||||
Three ways to catch shrinkflation before you overpay:
|
||||
|
||||
1. **Track unit prices** — Divide the shelf price by the size (oz, g, count). If the unit price goes up but the product looks the same, you are being shrunk.
|
||||
2. **Compare across brands** — A competing brand may offer more product for the same or lower price.
|
||||
3. **Use CartSnitch** — CartSnitch monitors unit prices on your tracked products and alerts you when a product you buy regularly gets smaller or more expensive.
|
||||
|
||||
---
|
||||
|
||||
## Does Shrinkflation Affect Store Brands Too?
|
||||
|
||||
Yes. Store brands (private label) also engage in shrinkflation, though they tend to do so less aggressively than name brands. National brands rely more heavily on shrinkflation because they cannot compete on price as easily as store brands do.
|
||||
|
||||
---
|
||||
|
||||
## Is There a Campaign or Movement Against Shrinkflation?
|
||||
|
||||
Consumer advocacy groups have lobbied for:
|
||||
- Mandatory unit price display at shelf level
|
||||
- Required advance notice when product sizes change
|
||||
- Clear "size changed" labels on packaging
|
||||
|
||||
CartSnitch is built to give consumers the data they need to make informed decisions — even before regulation catches up.
|
||||
|
||||
---
|
||||
|
||||
## How Is Shrinkflation Different From Price Gouging?
|
||||
|
||||
Shrinkflation is a gradual, product-level practice by manufacturers. Price gouging is typically a retailer or seller raising prices sharply during a supply crisis or emergency. Both harm consumers, but they are distinct practices.
|
||||
|
||||
Price gouging is illegal in many states during declared emergencies. Shrinkflation is legal year-round.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Shrinkflation is how brands quietly raise prices by giving you less product for the same money. It is legal, common, and affects the average family by hundreds of dollars per year. The only defense is tracking unit prices — and CartSnitch does that automatically.
|
||||
Reference in New Issue
Block a user