Skip to content

Commit b032c69

Browse files
Merge pull request #78 from FireBird-Technologies/develop
Develop
2 parents 33c4b5f + 9c1af81 commit b032c69

90 files changed

Lines changed: 8705 additions & 172 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""add featured_slots (paid featured publication bookings)
2+
3+
Revision ID: 0019
4+
Revises: 0018
5+
Create Date: 2026-07-13 00:00:00.000000
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
from sqlalchemy.dialects import postgresql
13+
14+
revision: str = "0019"
15+
down_revision: Union[str, None] = "0018"
16+
branch_labels: Union[str, Sequence[str], None] = None
17+
depends_on: Union[str, Sequence[str], None] = None
18+
19+
20+
def upgrade() -> None:
21+
op.create_table(
22+
"featured_slots",
23+
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
24+
sa.Column(
25+
"publication_id",
26+
postgresql.UUID(as_uuid=True),
27+
sa.ForeignKey("publications.id", ondelete="CASCADE"),
28+
nullable=False,
29+
),
30+
sa.Column(
31+
"user_id",
32+
postgresql.UUID(as_uuid=True),
33+
sa.ForeignKey("users.id", ondelete="CASCADE"),
34+
nullable=False,
35+
),
36+
sa.Column("start_date", sa.Date(), nullable=False),
37+
sa.Column("end_date", sa.Date(), nullable=False),
38+
sa.Column("duration_days", sa.Integer(), server_default="7", nullable=False),
39+
sa.Column("status", sa.String(length=16), server_default="pending", nullable=False),
40+
sa.Column("is_active", sa.Boolean(), server_default="false", nullable=False),
41+
sa.Column("hold_expires_at", sa.DateTime(timezone=True), nullable=True),
42+
sa.Column("stripe_session_id", sa.String(length=255), nullable=True),
43+
sa.Column("stripe_payment_intent_id", sa.String(length=255), nullable=True),
44+
sa.Column("amount_cents", sa.Integer(), nullable=False),
45+
sa.Column("currency", sa.String(length=8), server_default="usd", nullable=False),
46+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
47+
sa.Column("paid_at", sa.DateTime(timezone=True), nullable=True),
48+
)
49+
op.create_index("ix_featured_slots_publication_id", "featured_slots", ["publication_id"])
50+
op.create_index("ix_featured_slots_user_id", "featured_slots", ["user_id"])
51+
op.create_index("ix_featured_slots_start_date", "featured_slots", ["start_date"])
52+
op.create_index("ix_featured_slots_end_date", "featured_slots", ["end_date"])
53+
op.create_index("ix_featured_slots_status", "featured_slots", ["status"])
54+
op.create_index("ix_featured_slots_is_active", "featured_slots", ["is_active"])
55+
op.create_index("ix_featured_slots_created_at", "featured_slots", ["created_at"])
56+
op.create_index(
57+
"ix_featured_slots_stripe_session_id", "featured_slots", ["stripe_session_id"], unique=True
58+
)
59+
# At most one featured publication may be active at any time.
60+
op.create_index(
61+
"ix_featured_slots_one_active",
62+
"featured_slots",
63+
["is_active"],
64+
unique=True,
65+
postgresql_where=sa.text("is_active = true"),
66+
)
67+
68+
69+
def downgrade() -> None:
70+
op.drop_table("featured_slots")
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""add click_count to featured_slots
2+
3+
Counts outbound clicks from BlogHub to the featured publication's own links during
4+
its run — the traffic the buyer actually paid for.
5+
6+
Revision ID: 0020
7+
Revises: 0019
8+
Create Date: 2026-07-13 00:00:00.000000
9+
10+
"""
11+
from typing import Sequence, Union
12+
13+
import sqlalchemy as sa
14+
from alembic import op
15+
16+
revision: str = "0020"
17+
down_revision: Union[str, None] = "0019"
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
op.add_column(
24+
"featured_slots",
25+
sa.Column("click_count", sa.Integer(), server_default="0", nullable=False),
26+
)
27+
28+
29+
def downgrade() -> None:
30+
op.drop_column("featured_slots", "click_count")
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""add approval_status/approved_at to featured_slots
2+
3+
Paying reserves the dates but no longer puts the publication on the site: a booking
4+
must be approved by an admin before the reconcile job will activate it.
5+
6+
Revision ID: 0021
7+
Revises: 0020
8+
Create Date: 2026-07-13 00:00:00.000000
9+
10+
"""
11+
from typing import Sequence, Union
12+
13+
import sqlalchemy as sa
14+
from alembic import op
15+
16+
revision: str = "0021"
17+
down_revision: Union[str, None] = "0020"
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
op.add_column(
24+
"featured_slots",
25+
sa.Column("approval_status", sa.String(length=16), server_default="pending", nullable=False),
26+
)
27+
op.add_column(
28+
"featured_slots",
29+
sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True),
30+
)
31+
op.create_index("ix_featured_slots_approval_status", "featured_slots", ["approval_status"])
32+
33+
# Any booking that predates this migration was live under the old "paying is
34+
# enough" rule — grandfather it in rather than silently un-featuring it.
35+
op.execute(
36+
"UPDATE featured_slots SET approval_status = 'approved', approved_at = now() "
37+
"WHERE status = 'paid'"
38+
)
39+
40+
41+
def downgrade() -> None:
42+
op.drop_index("ix_featured_slots_approval_status", table_name="featured_slots")
43+
op.drop_column("featured_slots", "approved_at")
44+
op.drop_column("featured_slots", "approval_status")
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""add featured_emails (marketing email for a featured publication)
2+
3+
Revision ID: 0022
4+
Revises: 0021
5+
Create Date: 2026-07-13 00:00:00.000000
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
from sqlalchemy.dialects import postgresql
13+
14+
revision: str = "0022"
15+
down_revision: Union[str, None] = "0021"
16+
branch_labels: Union[str, Sequence[str], None] = None
17+
depends_on: Union[str, Sequence[str], None] = None
18+
19+
20+
def upgrade() -> None:
21+
op.create_table(
22+
"featured_emails",
23+
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
24+
sa.Column(
25+
"slot_id",
26+
postgresql.UUID(as_uuid=True),
27+
sa.ForeignKey("featured_slots.id", ondelete="CASCADE"),
28+
nullable=False,
29+
),
30+
sa.Column(
31+
"publication_id",
32+
postgresql.UUID(as_uuid=True),
33+
sa.ForeignKey("publications.id", ondelete="CASCADE"),
34+
nullable=False,
35+
),
36+
sa.Column("subject", sa.String(length=200), nullable=False),
37+
sa.Column("body", sa.Text(), nullable=False),
38+
sa.Column("author_approved", sa.Boolean(), server_default="false", nullable=False),
39+
sa.Column("admin_approved", sa.Boolean(), server_default="false", nullable=False),
40+
sa.Column("status", sa.String(length=16), server_default="draft", nullable=False),
41+
sa.Column("scheduled_at", sa.DateTime(timezone=True), nullable=True),
42+
sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True),
43+
sa.Column("recipient_count", sa.Integer(), server_default="0", nullable=False),
44+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
45+
)
46+
# One draft per booking — this is what makes draft creation idempotent.
47+
op.create_index("ix_featured_emails_slot_id", "featured_emails", ["slot_id"], unique=True)
48+
op.create_index("ix_featured_emails_status", "featured_emails", ["status"])
49+
op.create_index("ix_featured_emails_created_at", "featured_emails", ["created_at"])
50+
51+
52+
def downgrade() -> None:
53+
op.drop_table("featured_emails")
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""add author_timezone to featured_emails
2+
3+
The author now picks when their announcement is sent. `scheduled_at` already stores a
4+
true UTC instant; this records the IANA zone they chose it in, so we can show the time
5+
back to them exactly as they entered it.
6+
7+
Revision ID: 0023
8+
Revises: 0022
9+
Create Date: 2026-07-14 00:00:00.000000
10+
11+
"""
12+
from typing import Sequence, Union
13+
14+
import sqlalchemy as sa
15+
from alembic import op
16+
17+
revision: str = "0023"
18+
down_revision: Union[str, None] = "0022"
19+
branch_labels: Union[str, Sequence[str], None] = None
20+
depends_on: Union[str, Sequence[str], None] = None
21+
22+
23+
def upgrade() -> None:
24+
op.add_column(
25+
"featured_emails",
26+
sa.Column("author_timezone", sa.String(length=64), nullable=True),
27+
)
28+
29+
30+
def downgrade() -> None:
31+
op.drop_column("featured_emails", "author_timezone")
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""add button_text to featured_emails
2+
3+
The publication link is rendered as a single button rather than a linked title, and
4+
the author now chooses the button's label in the announcement step (e.g. "Read the
5+
article", "Visit the site"). Falls back to a generic label for rows drafted before
6+
this existed.
7+
8+
Revision ID: 0024
9+
Revises: 0023
10+
Create Date: 2026-07-14 00:00:00.000000
11+
12+
"""
13+
from typing import Sequence, Union
14+
15+
import sqlalchemy as sa
16+
from alembic import op
17+
18+
revision: str = "0024"
19+
down_revision: Union[str, None] = "0023"
20+
branch_labels: Union[str, Sequence[str], None] = None
21+
depends_on: Union[str, Sequence[str], None] = None
22+
23+
24+
def upgrade() -> None:
25+
op.add_column(
26+
"featured_emails",
27+
sa.Column("button_text", sa.String(length=60), nullable=True),
28+
)
29+
30+
31+
def downgrade() -> None:
32+
op.drop_column("featured_emails", "button_text")
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""add featured_email_recipients, and a "sending" status for featured_emails
2+
3+
Per-recipient send tracking for the marketing blast. Previously an email was marked
4+
`sent` the instant it was claimed, before a single message went out — a crash
5+
mid-blast then meant no way to tell who had actually received it, so a retry would
6+
either resend to everyone or (as chosen at the time) silently skip the rest.
7+
8+
Now each successful send is recorded as its own row, committed immediately after
9+
that recipient's email leaves. `status` gains a `sending` value: claiming an email
10+
moves it there (not to `sent`), and it only becomes `sent` once every subscribed
11+
user as of that pass has a recorded row. A crash mid-blast leaves the row at
12+
`sending`, which the scheduler picks back up on its next tick (or on restart) and
13+
resumes — skipping anyone already recorded, so nobody receives it twice.
14+
15+
Revision ID: 0025
16+
Revises: 0024
17+
Create Date: 2026-07-14 00:00:00.000000
18+
19+
"""
20+
from typing import Sequence, Union
21+
22+
import sqlalchemy as sa
23+
from alembic import op
24+
from sqlalchemy.dialects import postgresql
25+
26+
revision: str = "0025"
27+
down_revision: Union[str, None] = "0024"
28+
branch_labels: Union[str, Sequence[str], None] = None
29+
depends_on: Union[str, Sequence[str], None] = None
30+
31+
32+
def upgrade() -> None:
33+
op.create_table(
34+
"featured_email_recipients",
35+
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
36+
sa.Column(
37+
"email_id",
38+
postgresql.UUID(as_uuid=True),
39+
sa.ForeignKey("featured_emails.id", ondelete="CASCADE"),
40+
nullable=False,
41+
),
42+
sa.Column(
43+
"user_id",
44+
postgresql.UUID(as_uuid=True),
45+
sa.ForeignKey("users.id", ondelete="CASCADE"),
46+
nullable=False,
47+
),
48+
sa.Column("sent_at", sa.DateTime(timezone=True), nullable=False),
49+
)
50+
# The whole point of this table: a retry checks "has this (email, user) pair
51+
# already been recorded" before sending again. Unique makes that check race-safe
52+
# even if two scheduler ticks somehow overlapped.
53+
op.create_index(
54+
"ix_featured_email_recipients_email_user",
55+
"featured_email_recipients",
56+
["email_id", "user_id"],
57+
unique=True,
58+
)
59+
op.create_index(
60+
"ix_featured_email_recipients_email_id",
61+
"featured_email_recipients",
62+
["email_id"],
63+
)
64+
65+
66+
def downgrade() -> None:
67+
op.drop_table("featured_email_recipients")
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""add is_unlisted to publications
2+
3+
Backs the "feature any link" flow: a publication created from an arbitrary URL
4+
(rather than a normal submission) to fill a paid featured slot. It's a real,
5+
queryable Publication row — same table, same pipeline — just excluded from public
6+
browsing (home feed, search, categories, tags, roundups, other users' view of the
7+
owner's profile) via this flag. The owner can still see their own.
8+
9+
Revision ID: 0026
10+
Revises: 0025
11+
Create Date: 2026-07-14 00:00:00.000000
12+
13+
"""
14+
from typing import Sequence, Union
15+
16+
import sqlalchemy as sa
17+
from alembic import op
18+
19+
revision: str = "0026"
20+
down_revision: Union[str, None] = "0025"
21+
branch_labels: Union[str, Sequence[str], None] = None
22+
depends_on: Union[str, Sequence[str], None] = None
23+
24+
25+
def upgrade() -> None:
26+
op.add_column(
27+
"publications",
28+
sa.Column("is_unlisted", sa.Boolean(), nullable=False, server_default="false"),
29+
)
30+
op.create_index("ix_publications_is_unlisted", "publications", ["is_unlisted"])
31+
32+
33+
def downgrade() -> None:
34+
op.drop_index("ix_publications_is_unlisted", table_name="publications")
35+
op.drop_column("publications", "is_unlisted")

0 commit comments

Comments
 (0)