Members Suite — Blueprint
Blueprint — Members Suite
Architect Agent 3 of the Pancha · 2026-05-02 · slug
members
1. Module summary
| field | value |
|---|---|
| Name | Members Suite |
| Slug | members |
| Kind | CRM |
| Status | partial — extends DISA's existing members registry |
| Subpackage | extends existing src/aayojana/routers/members.py (+ new admin templates under templates/admin/members_suite/) |
| API prefix | /api/members (existing, extended) |
The Members Suite presents the institution's people in seven role-driven dashboards — Founders, Substantial Donors, Recurring Donors, Service Recipients, Casual Associates, Volunteers, and Staff Index — without flattening them into seven separate registries. The single canonical members table (already live, with name, gothra, nakshatram, dob, photo, family fields) remains the source of truth. This module adds categorization: a member_categorizations table that lets one person belong to multiple categories simultaneously (a recurring donor who also volunteers, who is also the parent of a Vedapathashala student). Auto-categorization rules pull from Vitta donations, recurring payment events, and Staff Details to keep segments fresh. Communication preferences, mailing-list subscriptions, donor-aggregate denormalisation, and a volunteer skill inventory complete the suite. Staff Index is a read-only join — staff are still owned by the Staff Details module (sibling blueprint).
2. Data model
A categorization table is preferred over extending tag taxonomy because:
- Tags are flat strings on
tags, with no schema forsince_date,threshold_metevidence, orrecognition_level. Donor categorization needs structured fields. - Categories drive workflows (auto-categorize on donation threshold, auto-decategorize on staff exit) — workflow logic should not pivot on string equality across a tag table that is also used for ad-hoc tags.
- Tags remain useful for ad-hoc segmentation (
mysuru-camp-2025,prasadam-volunteer-festivals-only) and the suite still consumes them via the existingTagmodel.
# src/aayojana/models/member_categorization.py
from datetime import date, datetime
from sqlalchemy import (
Boolean, Date, DateTime, ForeignKey, Index, Integer, JSON,
Numeric, String, Text, UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from aayojana.models.base import AuditMixin, Base, TenantMixin, uuid4_str
class MemberCategorization(Base, AuditMixin, TenantMixin):
"""One row per (member, category). A person can hold many.
`category` taxonomy (closed set):
founder · substantial_donor · recurring_donor ·
service_recipient · casual_associate · volunteer · staff_index
"""
__tablename__ = "member_categorizations"
__table_args__ = (
UniqueConstraint("member_id", "category", name="uq_member_category"),
Index("ix_member_cat_category_active", "category", "active"),
Index("ix_member_cat_tenant_category", "tenant_id", "category"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
member_id: Mapped[str] = mapped_column(
String(36), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
)
category: Mapped[str] = mapped_column(String(24), nullable=False)
since_date: Mapped[date] = mapped_column(Date, nullable=False)
until_date: Mapped[date | None] = mapped_column(Date, nullable=True)
active: Mapped[bool] = mapped_column(Boolean, server_default="true")
# auto-categorisation evidence — e.g. {"trigger":"vitta.donor_aggregate",
# "threshold":100000, "actual":135000, "fy":"FY2025-26"}
threshold_met: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# bronze | silver | gold | platinum | founder | (free-text per tenant)
recognition_level: Mapped[str | None] = mapped_column(String(40), nullable=True)
# automatic | manual | imported
source: Mapped[str] = mapped_column(String(16), nullable=False, server_default="manual")
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
categorized_by: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
member: Mapped["Member"] = relationship() # type: ignore[name-defined]
class MemberSubscriptionList(Base, AuditMixin, TenantMixin):
"""A named mailing list (Monthly Newsletter, Annadana Donors, FCRA
Donors, Festival Devotees). Owned here in Members Suite because
list membership is a member attribute; the **content** of each
issue is owned by Newsletter (Agent 5)."""
__tablename__ = "member_subscription_lists"
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_subscription_list_code"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
code: Mapped[str] = mapped_column(String(40), nullable=False)
name: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# email | postal | whatsapp | multi
primary_channel: Mapped[str] = mapped_column(String(16), nullable=False)
requires_double_optin: Mapped[bool] = mapped_column(Boolean, server_default="true")
active: Mapped[bool] = mapped_column(Boolean, server_default="true")
class MemberSubscription(Base, AuditMixin, TenantMixin):
"""member ↔ subscription_list with opt-in lineage."""
__tablename__ = "member_subscriptions"
__table_args__ = (
UniqueConstraint("member_id", "list_id", name="uq_member_subscription"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
member_id: Mapped[str] = mapped_column(
String(36), ForeignKey("members.id", ondelete="CASCADE"), nullable=False, index=True
)
list_id: Mapped[str] = mapped_column(
String(36), ForeignKey("member_subscription_lists.id", ondelete="CASCADE"),
nullable=False, index=True,
)
# pending_optin | active | unsubscribed | bounced
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="pending_optin")
optin_token: Mapped[str | None] = mapped_column(String(64), nullable=True)
optin_confirmed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
optout_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
optout_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
class MemberCommunicationPreferences(Base, AuditMixin, TenantMixin):
"""Per-channel opt-in flags + quiet hours.
1:1 with members. Read by Comms (Agent 5) before every send.
"""
__tablename__ = "member_communication_preferences"
__table_args__ = (
UniqueConstraint("member_id", name="uq_member_comm_prefs"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
member_id: Mapped[str] = mapped_column(
String(36), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
)
email_optin: Mapped[bool] = mapped_column(Boolean, server_default="true")
whatsapp_optin: Mapped[bool] = mapped_column(Boolean, server_default="false")
sms_optin: Mapped[bool] = mapped_column(Boolean, server_default="false")
postal_optin: Mapped[bool] = mapped_column(Boolean, server_default="true")
receipt_only: Mapped[bool] = mapped_column(Boolean, server_default="false")
# JSON of {fri_evening:false, festival_only:true, languages:["sa","kn","en"]}
rules: Mapped[dict | None] = mapped_column(JSON, nullable=True)
preferred_language: Mapped[str | None] = mapped_column(String(8), nullable=True)
last_updated_by_member_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
class MemberDonorAggregate(Base, AuditMixin, TenantMixin):
"""Denormalised donor totals refreshed nightly (or on payment event).
Used by Substantial-Donor auto-categorization without scanning
the full transactions table per request.
"""
__tablename__ = "member_donor_aggregates"
__table_args__ = (
UniqueConstraint("member_id", "fund_code", "fy_label",
name="uq_donor_agg_member_fund_fy"),
Index("ix_donor_agg_total", "tenant_id", "lifetime_total"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
member_id: Mapped[str] = mapped_column(
String(36), ForeignKey("members.id", ondelete="CASCADE"), nullable=False, index=True
)
# general | annadana | building | fcra | endowment | _all_
fund_code: Mapped[str] = mapped_column(String(20), nullable=False)
fy_label: Mapped[str] = mapped_column(String(12), nullable=False) # FY2025-26
fy_total: Mapped[str] = mapped_column(Numeric(14, 2), nullable=False, server_default="0")
lifetime_total: Mapped[str] = mapped_column(Numeric(14, 2), nullable=False, server_default="0")
last_donation_date: Mapped[date | None] = mapped_column(Date, nullable=True)
donation_count: Mapped[int] = mapped_column(Integer, server_default="0")
is_recurring: Mapped[bool] = mapped_column(Boolean, server_default="false")
razorpay_subscription_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
refreshed_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class VolunteerSkill(Base, AuditMixin, TenantMixin):
"""Per-volunteer skill row. Only members with an active
`member_categorizations.category='volunteer'` get rows here."""
__tablename__ = "volunteer_skills"
__table_args__ = (
UniqueConstraint("member_id", "skill_code", name="uq_volunteer_skill"),
Index("ix_volunteer_skill_code", "skill_code"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
member_id: Mapped[str] = mapped_column(
String(36), ForeignKey("members.id", ondelete="CASCADE"), nullable=False, index=True
)
# Tenant-extensible catalog: driving · cooking · accounting ·
# photography · sound · samagri_setup · manuscript · sanskrit_typing …
skill_code: Mapped[str] = mapped_column(String(40), nullable=False)
proficiency: Mapped[int] = mapped_column(Integer, server_default="3") # 1..5
# weekends | weekdays | full_time | events_only | remote
availability: Mapped[str | None] = mapped_column(String(20), nullable=True)
certified: Mapped[bool] = mapped_column(Boolean, server_default="false")
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class VolunteerEventParticipation(Base, AuditMixin, TenantMixin):
"""Roster log — populated when an Events Planner roster
(Agent 4) is finalised. One row per (member, event)."""
__tablename__ = "volunteer_event_participations"
__table_args__ = (
UniqueConstraint("member_id", "event_id", name="uq_volunteer_event"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
member_id: Mapped[str] = mapped_column(
String(36), ForeignKey("members.id", ondelete="CASCADE"), nullable=False, index=True
)
event_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
# FK to events table when Agent 4 lands; left soft for now
role: Mapped[str | None] = mapped_column(String(63), nullable=True)
hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
rating: Mapped[int | None] = mapped_column(Integer, nullable=True) # 1..5
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
NOT redefined: Member.name, Member.dob, Member.photo, Member.gotram, Member.nakshatram_id, Profile.placeOfBirth etc. — those are already on members / profiles. The Members Suite reads them via the existing eager-load relationships.
3. Reuse map
| Existing model | Used as | Where |
|---|---|---|
members.Member |
Canonical person row | every new table FK |
members.Address, members.Profile, members.Awardee |
Detail-page sub-sections | Member detail templates |
members.Tag |
Ad-hoc segmentation overlay | unchanged; Comms reads both tags and member_categorizations for audience targeting |
transaction.Transaction |
Donation history source | aggregated nightly into member_donor_aggregates |
payment_event.PaymentEvent |
Razorpay subscription state | maps razorpay_subscription_id → recurring_donor categorization |
seva.Seva |
Service-recipient identification | a member with any non-cancelled Seva is auto-categorized service_recipient |
awardees.Awardee |
Founder evidence (legacy data) | imported into MemberCategorization with category=founder, source=imported |
staff_profiles (Staff blueprint) |
Drives staff_index category and the Staff Index dashboard |
|
dispatch.DispatchRecord |
Postal log | already powers Communication History tab |
organisations, branches, users, memberships |
Tenant scoping + actors | inherited / FK |
The Members Suite does not introduce a parallel Member entity, parallel address store, parallel donor table, or parallel staff table. Every category dashboard is a projection over the existing canonical data plus the categorization layer.
4. API surface
Existing routes under /api/members (CRUD, search, photo) stay. New routes are additive:
| Method | Path | Purpose |
|---|---|---|
POST |
/api/members/{member_id}/categorize |
Add or update a categorization (manual) |
DELETE |
/api/members/{member_id}/categorize/{category} |
Decategorize (sets active=false, until_date=today) |
GET |
/api/members/by-category/{category} |
Paginated list filtered by category, plus joined display fields |
GET |
/api/members/categories/summary |
Counts per category for the dashboard |
GET |
/api/members/segments/founders |
Convenience alias for ?category=founder |
GET |
/api/members/segments/substantial-donors |
Sorted by lifetime_total desc; reads member_donor_aggregates |
GET |
/api/members/segments/recurring-donors |
Active Razorpay subscriptions |
GET |
/api/members/segments/service-recipients |
Members with active sevas |
GET |
/api/members/segments/casual-associates |
Default fallback bucket |
GET |
/api/members/segments/volunteers |
Volunteer roster |
GET |
/api/members/segments/staff |
Read-only join with staff_profiles |
POST |
/api/members/recompute-substantial-donor/{member_id} |
Force a re-evaluation against current threshold |
POST |
/api/members/recompute-aggregates |
Tenant-wide nightly job hook (admin only) |
GET |
/api/members/{member_id}/donor-history |
Merged view: transactions + payment_events + donor_aggregates |
PUT |
/api/members/{member_id}/communication-preferences |
Channel opt-ins + rules |
GET |
/api/subscription-lists |
List available mailing lists |
POST |
/api/subscription-lists |
Create list (admin) |
POST |
/api/members/{member_id}/subscriptions |
Subscribe member to a list (sets pending_optin → sends Comms confirm) |
POST |
/api/subscriptions/confirm/{token} |
Public endpoint — double-opt-in confirm |
POST |
/api/subscriptions/unsubscribe/{token} |
Public endpoint |
GET |
/api/members/{member_id}/volunteer-skills |
List skills for one volunteer |
POST |
/api/members/{member_id}/volunteer-skills |
Add a skill |
POST |
/api/volunteers/match |
Body {event_id, skills_required, on_date} returns ranked candidate list |
POST |
/api/volunteers/{member_id}/event-participation |
Log post-event participation row |
UI pages (HTML, served from existing routers/members.py-adjacent template includes):
- /admin/members — current list, gains category-filter chips
- /admin/members/{id} — current detail, gains tabs: Donations · Subscriptions · Comm-prefs · Volunteer-skills · Categories
- /admin/segments/founders … /admin/segments/staff — one dashboard per category
- /admin/segments/_dashboard — overview tile grid
- /admin/volunteers/match — skill-matcher tool
5. Service layer
src/aayojana/services/categorization_service.py
async def categorize_member(
db: AsyncSession, member_id: str, category: str,
threshold_data: dict | None = None,
evidence: str | None = None,
recognition_level: str | None = None,
source: str = "manual",
actor: User = ...,
) -> MemberCategorization:
"""Idempotent upsert. If a row already exists for (member, category):
- active=True → no-op (return existing)
- active=False → reactivate, set since_date=today, push old row's
until_date forward; emit 'member.recategorized' audit event.
Validates `category` against the closed enum. Writes audit_event."""
async def decategorize_member(
db: AsyncSession, member_id: str, category: str, reason: str, actor: User
) -> MemberCategorization:
"""Sets active=False, until_date=today, notes=reason. Emits
'member.decategorized' audit event."""
async def compute_substantial_donor_status(
db: AsyncSession, tenant_id: str, member_id: str,
since_date: date | None = None,
) -> bool:
"""Reads member_donor_aggregates (already nightly-refreshed).
Returns True iff lifetime_total ≥ tenant.substantial_donor_threshold
(default ₹1,00,000; tenant-overridable — see Open Q #3).
If True and no active categorization exists, calls categorize_member
automatically with source='automatic'."""
async def refresh_donor_aggregates(
db: AsyncSession, tenant_id: str,
only_member_id: str | None = None,
) -> int:
"""Nightly job. SQL aggregates over transactions grouped by
(member, fund, FY). Updates member_donor_aggregates with merge
semantics. Returns rows affected. Triggered by a scheduled task
or by payment_webhooks on every confirmed donation."""
async def auto_categorize_recurring_donor(
db: AsyncSession, payment_event: PaymentEvent
) -> MemberCategorization | None:
"""Called from payment_webhooks router when event_type starts with
'subscription.'. Resolves the subscription's customer → member
→ upserts categorization (active or paused based on event)."""
async def list_segment(
db: AsyncSession, tenant_id: str, category: str,
limit: int, offset: int, sort: str | None,
) -> tuple[list[Member], int]:
"""Returns (members, total). Joins member_categorizations and,
for substantial_donors, also joins member_donor_aggregates so
the UI can show lifetime_total without a second query."""
async def get_donor_history_merged(
db: AsyncSession, member_id: str
) -> dict:
"""Returns:
{ transactions:[…raw rows…],
payment_events:[…razorpay subs…],
aggregates:[…by fund × FY…],
certificates:[…80G placeholder, populated by Vitta later…] }"""
async def update_communication_preferences(
db: AsyncSession, member_id: str, prefs: PrefsIn, actor: User
) -> MemberCommunicationPreferences:
"""Upsert. Sets last_updated_by_member_at when actor==self."""
async def subscribe_member(
db: AsyncSession, member_id: str, list_id: str, actor: User
) -> MemberSubscription:
"""Creates row with status=pending_optin, generates token,
enqueues Comms 'subscription.optin' email. Skips if list does
not require double-opt-in (then sets status=active immediately)."""
async def confirm_subscription_optin(
db: AsyncSession, token: str
) -> MemberSubscription:
"""Public-facing. Sets status=active, optin_confirmed_at=now()."""
async def find_volunteers_for_event(
db: AsyncSession, tenant_id: str, event_id: str,
skills_required: list[str], on_date: date | None = None,
min_proficiency: int = 3,
) -> list[Member]:
"""Subset of members where active categorization=volunteer AND
every required skill_code exists at proficiency ≥ min_proficiency.
Excludes members with a conflicting same-day participation row.
Ranks by historical rating average."""
async def link_staff_index(
db: AsyncSession, member_id: str
) -> MemberCategorization:
"""Called by staff_service.onboard_staff. Creates a
category=staff_index categorization so the Staff Index dashboard
automatically reflects the new hire without manual sync."""
6. UI / Templates
Templates under src/aayojana/templates/admin/members_suite/ (new directory). The existing templates/admin/members.html is extended in-place to add category chips.
| Template | Surface |
|---|---|
members_suite/dashboard.html |
Overview — tile per category with count + recent additions |
members_suite/_segment_grid.html |
Reusable grid: one row per member, columns vary by category (e.g. lifetime_total for substantial_donors) |
members_suite/founders.html |
Wraps the grid with founder-specific recognition fields |
members_suite/substantial_donors.html |
Sorted by lifetime_total desc; "Generate 80G" deep-link to Vitta |
members_suite/recurring_donors.html |
Active Razorpay subscriptions; cancellation-risk indicator |
members_suite/service_recipients.html |
Members with sevas; upcoming-seva preview |
members_suite/casual_associates.html |
Fallback bucket; bulk-promote action |
members_suite/volunteers.html |
Skill matrix view; availability timeline |
members_suite/staff_index.html |
Read-only join with staff_profiles; deep-link → /staff/{id} |
members_suite/_categorize_modal.html |
Add-category dialog with threshold-data form |
members_suite/donor_history.html |
Merged view (transactions + payment_events) |
members_suite/comm_prefs.html |
Per-channel toggles + language selector + rules JSON editor |
members_suite/subscriptions.html |
Lists user is opted into + double-opt-in status badges |
members_suite/volunteer_match.html |
Form: select skills + date → ranked candidate list |
Detail-page tab order on /admin/members/{id}: Personal · Addresses · Profile · Donations · Sevas · Subscriptions · Comm-prefs · Categories · Volunteer (only if volunteer category) · Staff (only if staff_index category — opens Staff Details).
7. Migration plan
| Rev | Title | Tables |
|---|---|---|
| 0016 | member_categorization_core |
member_categorizations · member_communication_preferences |
| 0017 | member_subscriptions_aggregates |
member_subscription_lists · member_subscriptions · member_donor_aggregates |
| 0018 | volunteer_inventory |
volunteer_skills · volunteer_event_participations |
(Numbering coordinates with Staff Details — Staff uses 0016–0018 of its own track; if migrations are linearised across the repo, allocate Members 0019–0021. The submitted plan assumes Staff and Members run as parallel branches that converge at 0022.)
Each migration includes:
1. Backfill: INSERT INTO member_categorizations (member_id, category='casual_associate', ...) FROM members WHERE NOT EXISTS … so every existing member starts in casual_associate and is promoted by subsequent jobs.
2. A nightly cron stub that calls refresh_donor_aggregates is provisioned as part of 0017.
8. Cross-module dependencies
Reads from
- transactions — donation history (Vitta will eventually own these accounting-side; the transactions table is shared)
- payment_events — Razorpay subscription state for recurring-donor classification
- sevas — service-recipient identification
- staff_profiles (Staff blueprint) — staff_index dashboard read-only join
- tags — ad-hoc segments overlayed on category dashboards
- awardees — founder evidence at import time
- dispatch_records — Communication History tab
Writes to
- Audit Trail (Agent 2) — every categorize / decategorize / preferences change emits member.category.* events
- Comms (Agent 5) — double-opt-in confirmations, anniversary greetings (birthDate + birthYear from members), 80G dispatch reminders (handed to Vitta, dispatched by Comms)
- Newsletter (Agent 5) — every send pulls subscriber list via member_subscriptions joined to member_subscription_lists
- Events Planner (Agent 4) — find_volunteers_for_event is the public API; Events writes back via volunteer_event_participations
- Vitta Fin (Agent 1) — substantial-donor threshold reads org-level config; donor aggregate refresh consumes Vitta-confirmed transactions
- Staff Details (sibling) — link_staff_index ensures staff_index category always tracks staff_profiles 1:1
9. Implementation phases
Phase A — categorization scaffold + manual workflow (one sprint)
- Migration 0016
- MemberCategorization model + categorize/decategorize CRUD
- Category-filter chips on existing /admin/members
- Backfill: every existing member → casual_associate
- Per-category dashboards with manual promotion buttons
Phase B — auto-categorization + comm-prefs + subscriptions (two sprints)
- Migrations 0017
- member_donor_aggregates nightly refresh job
- compute_substantial_donor_status + auto-promotion
- Recurring-donor auto-categorization wired into payment_webhooks
- Communication preferences editor
- Subscription lists with double-opt-in flow
- Newsletter hand-off API for Agent 5
Phase C — volunteers + staff index + match tool (one sprint)
- Migration 0018
- Volunteer skill matrix UI
- find_volunteers_for_event API + match-tool template
- Staff Index dashboard (read-only join with Staff blueprint)
- link_staff_index invoked from staff onboarding (cross-module)
- Donor-history merged view
10. Open questions
- Multi-category membership. Confirmed yes —
(member_id, category)unique not(member_id). A donor-volunteer is two active rows. Recommend ratify. - Substantial-donor threshold definition. Fixed amount, top-N percentile per fund, or tenant-configurable? Proposed: tenant-configurable per fund (
organisations.substantial_donor_thresholdsJSON column added in migration 0017), default ₹1,00,000 lifetime to General. Decision needed. - Founder vs Substantial Donor rank. If a person qualifies as both, do dashboards show them under both or only the higher? Proposed: both — dashboards are projections, not partitions. Decision needed.
- Casual Associate as default. Should every member without an explicit category land in casual_associate, or in no category at all? Proposed: explicit casual_associate row created on member insert (via DB trigger or service hook) — keeps dashboard counts consistent. Decision needed.
- Recurring donor lapse handling. When a Razorpay subscription pauses, should we decategorize after N days (auto) or wait for explicit cancellation? Proposed: decategorize after 90 days of no successful charge; keep
until_dateso reactivation is detectable. Decision needed. - Service recipient lifetime. Once-off seva (one-time kalyanotsavam) — does the member remain
service_recipientforever? Proposed: rolling 5-year window (since last seva). Decision needed. - Volunteer skill catalog. Tenant-extensible JSON catalog or fixed enum? Proposed: tenant-extensible (default seed: driving, cooking, accounting, photography, sound, samagri_setup, manuscript, sanskrit_typing, chandas_recitation). Decision needed.
- Staff Index sync direction. Should Staff Details writes always trigger a Members Suite categorization upsert (preferred — automatic), or should the Members Suite poll? Proposed: automatic via
link_staff_indexcall insideonboard_staff. Decision needed. - Communication preferences inheritance. If a member opts out of email globally, do they still receive transactional emails (donation receipts, 80G certs)? Proposed:
receipt_onlyflag — opt-out from campaigns but transactional always allowed (legal requirement). Decision needed. - DPDP Act / GDPR posture. Double-opt-in mandatory for newsletters but not for transactional Comms — codified via
requires_double_optinon each list. Are we comfortable with this default? Decision needed.