| Field | Value |
|---|---|
| Version | 1.0 |
| Last Updated | 2025-XX-XX |
| Status | Draft |
| Related | 01-PRD.md, 02-USER-STORIES.md |
This document defines the database schema for MyUglyRocks, including:
- Entity definitions with all fields
- Relationships between entities
- Constraints and indexes
- Data type conventions
Database: PostgreSQL ORM: Entity Framework Core
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ User │ │ Cycle │ │ StageRun │
├─────────────┤ ├─────────────┤ ├─────────────┤
│ Id (PK) │──1:N──│ Id (PK) │──1:N──│ Id (PK) │
│ Email │ │ UserId (FK) │ │ CycleId(FK) │
│ Username │ │ Name │ │ BarrelId(FK)│
│ ... │ │ Status │ │ StageName │
└─────────────┘ │ ... │ │ ... │
│ └─────────────┘ └─────────────┘
│ │ │
│ │ │
│ ┌─────┴─────┐ │
│ │ │ │
│ ▼ │ ▼
│ ┌─────────────┐ │ ┌─────────────┐
│ │CycleSpecimen│ │ │CleaningRun │
│ ├─────────────┤ │ ├─────────────┤
│ │ CycleId(FK) │ │ │ Id (PK) │
│ │SpecimenId │ │ │StageRunId │
│ └─────────────┘ │ │ ... │
│ │ │ └─────────────┘
│ ▼ │ │
│ ┌─────────────┐ │ │
│ │ Specimen │ │ ▼
│ ├─────────────┤ │ ┌─────────────┐
│ │ Id (PK) │ │ │CleaningMat │
│ │ CommonName │ │ ├─────────────┤
│ │ ... │ │ │CleanRunId │
│ └─────────────┘ │ │MaterialId │
│ │ └─────────────┘
│ │
│ ▼
│ ┌─────────────┐
│ │StageMaterial│
│ ├─────────────┤
│ │StageRunId │
│ │MaterialId │
│ └─────────────┘
│ │
│ ▼
│ ┌─────────────┐
│ │ Material │
│ ├─────────────┤
│ │ Id (PK) │
│ │ CommonName │
│ └─────────────┘
│
├──1:N──┌─────────────┐ ┌─────────────┐
│ │ Tumbler │──1:N──│ Barrel │
│ ├─────────────┤ ├─────────────┤
│ │ Id (PK) │ │ Id (PK) │
│ │ UserId (FK) │ │TumblerId(FK)│
│ │ Brand │ │ Capacity │
│ └─────────────┘ │ Nickname │
│ └─────────────┘
│ │
│ ──────┘
│ │ (FK to StageRun.BarrelId)
│
├──1:N──┌─────────────┐ ┌─────────────┐
│ │ Post │──1:N──│ Comment │
│ ├─────────────┤ ├─────────────┤
│ │ Id (PK) │ │ Id (PK) │
│ │ UserId (FK) │ │ PostId (FK) │
│ │ CycleId(FK) │ │ UserId (FK) │
│ └─────────────┘ │ ParentId │
│ │ └─────────────┘
│ │
│ ├──1:N──┌─────────────┐
│ │ │ Vote │
│ │ ├─────────────┤
│ │ │ Id (PK) │
│ │ │ PostId (FK) │
│ │ │ UserId (FK) │
│ │ └─────────────┘
│ │
│ └──1:N──┌─────────────┐
│ │ PostPhoto │
│ ├─────────────┤
│ │ PostId (FK) │
│ │ PhotoId(FK) │
│ └─────────────┘
│
│ ┌─────────────┐
└──1:1──│UserSettings │
├─────────────┤
│ UserId (PK) │
│ Timezone │
│ ... │
└─────────────┘
┌─────────────┐
StageRun ──1:N── │ Photo │
├─────────────┤
│ Id (PK) │
│StageRunId │
│ PhotoType │
└─────────────┘
- Tables: PascalCase, singular (e.g.,
User,Cycle,StageRun) - Columns: PascalCase (e.g.,
UserId,DateCreated) - Primary Keys:
{TableName}Id(GUID) - e.g.,UserId,CycleId,AuditLogId - Foreign Keys:
{ReferencedTable}Id(e.g.,UserId,CycleId) - same as PK of referenced table
All entities implement one or more of these interfaces to ensure consistent naming across the codebase.
| Field | Type | Description |
|---|---|---|
{TableName}Id |
GUID | Primary key (e.g., UserId, CycleId) |
DateCreated |
timestamptz | Record creation time (UTC) |
DateUpdated |
timestamptz | Last modification time (UTC) |
public interface IAuditable<TKey>
{
TKey Id { get; set; } // Named {TableName}Id in each entity
DateTime DateCreated { get; set; }
DateTime DateUpdated { get; set; }
}Extends IAuditable with user tracking. Apply to: Specimen, Material, and other reference data tables.
| Field | Type | Description |
|---|---|---|
UserCreated |
uuid | FK → User who created the record |
UserUpdated |
uuid | FK → User who last updated the record |
public interface IUserAuditable : IAuditable
{
Guid UserCreated { get; set; }
Guid UserUpdated { get; set; }
}| Field | Type | Description |
|---|---|---|
IsDeleted |
boolean | Soft delete flag (default: false) |
DateDeleted |
timestamptz? | When deleted (nullable) |
public interface ISoftDeletable
{
bool IsDeleted { get; set; }
DateTime? DateDeleted { get; set; }
}Apply to: StageMaterial, CleaningMaterial - entities that track amounts in user-friendly units with canonical storage.
| Field | Type | Description |
|---|---|---|
DisplayAmount |
decimal(10,2) | User-entered amount |
DisplayUnit |
varchar(20) | User's unit (tsp, tbsp, g, ml, etc.) |
AmountGrams |
decimal(10,2) | Canonical amount for solids |
AmountMilliliters |
decimal(10,2) | Canonical amount for liquids |
public interface IWeightable
{
decimal? DisplayAmount { get; set; }
string? DisplayUnit { get; set; }
decimal? AmountGrams { get; set; }
decimal? AmountMilliliters { get; set; }
}// Note: Each entity defines its own {TableName}Id property
public abstract class BaseEntity : IAuditable<Guid>
{
// Id property named {TableName}Id in each derived class
public abstract Guid Id { get; set; }
public DateTime DateCreated { get; set; }
public DateTime DateUpdated { get; set; }
}
public abstract class BaseUserAuditedEntity : BaseEntity, IUserAuditable
{
public Guid UserCreated { get; set; }
public Guid UserUpdated { get; set; }
// Navigation properties
public virtual User CreatedByUser { get; set; }
public virtual User UpdatedByUser { get; set; }
}
public abstract class BaseSoftDeletableEntity : BaseEntity, ISoftDeletable
{
public bool IsDeleted { get; set; }
public DateTime? DateDeleted { get; set; }
}
public abstract class BaseWeightableEntity : BaseEntity, IWeightable
{
public decimal? DisplayAmount { get; set; }
public string? DisplayUnit { get; set; }
public decimal? AmountGrams { get; set; }
public decimal? AmountMilliliters { get; set; }
}| Entity | PK Name | IAuditable | IUserAuditable | ISoftDeletable | IWeightable |
|---|---|---|---|---|---|
| User | UserId |
✅ | ❌ | ❌ | ❌ |
| UserSettings | UserId |
✅ | ❌ | ❌ | ❌ |
| UserSpecimen | UserSpecimenId |
✅ | ❌ | ✅ | ❌ |
| Cycle | CycleId |
✅ | ❌ | ✅ | ❌ |
| CycleSpecimen | CycleId + SpecimenId |
✅ | ❌ | ❌ | ❌ |
| Specimen | SpecimenId |
✅ | ✅ | ❌ | ❌ |
| StageRun | StageRunId |
✅ | ❌ | ✅ | ❌ |
| CleaningRun | CleaningRunId |
✅ | ❌ | ❌ | ❌ |
| Material | MaterialId |
✅ | ✅ | ❌ | ❌ |
| StageMaterial | StageMaterialId |
✅ | ❌ | ❌ | ✅ |
| CleaningMaterial | CleaningMaterialId |
✅ | ❌ | ❌ | ✅ |
| TumblerModel | TumblerModelId |
✅ | ❌ | ❌ | ❌ |
| Tumbler | TumblerId |
✅ | ❌ | ❌ | ❌ |
| Barrel | BarrelId |
✅ | ❌ | ❌ | ❌ |
| BarrelNickname | NicknameId |
✅ | ❌ | ❌ | |
| Photo | PhotoId |
✅ | ❌ | ✅ | ❌ |
| Post | PostId |
✅ | ❌ | ✅ | ❌ |
| PostPhoto | PostId + PhotoId |
✅ | ❌ | ❌ | ❌ |
| Comment | CommentId |
✅ | ❌ | ✅ | ❌ |
| CommentReport | CommentReportId |
✅ | ❌ | ❌ | ❌ |
| Vote | VoteId |
✅ | ❌ | ❌ | ❌ |
| SpecimenSuggestion | SpecimenSuggestionId |
✅ | ❌ | ❌ | ❌ |
| AuditLog | AuditLogId |
✅ | ❌ | ❌ | ❌ |
| EmailVerificationToken | EmailVerificationTokenId |
✅ | ❌ | ❌ | ❌ |
| PasswordResetToken | PasswordResetTokenId |
✅ | ❌ | ❌ | ❌ |
| RefreshToken | RefreshTokenId |
✅ | ❌ | ❌ | ❌ |
| PostReport | PostReportId |
✅ | ❌ | ❌ | ❌ |
| MaterialSuggestion | MaterialSuggestionId |
✅ | ❌ | ❌ | ❌ |
| ScheduledReminder | ScheduledReminderId |
✅ | ❌ | ❌ | ❌ |
| Template | TemplateId |
✅ | ❌ | ❌ | ❌ |
| Tag | TagId |
✅ | ❌ | ❌ | ❌ |
| CycleTag | CycleId + TagId |
✅ | ❌ | ❌ | ❌ |
| PostTag | PostId + TagId |
✅ | ❌ | ❌ | ❌ |
*
Entities implementing ISoftDeletable include:
| Field | Type | Description |
|---|---|---|
IsDeleted |
boolean | Soft delete flag (default: false) |
DateDeleted |
timestamptz? | When deleted (nullable) |
| Concept | PostgreSQL Type | C# Type |
|---|---|---|
| Identifiers | uuid | Guid |
| Strings (short) | varchar(255) | string |
| Strings (long) | text | string |
| Dates | date | DateOnly |
| Timestamps | timestamptz | DateTime (UTC) |
| Durations | integer | int (minutes/hours) |
| Money | decimal(10,2) | decimal |
| Booleans | boolean | bool |
| Enums | smallint | enum (int) |
Core user account.
| Column | Type | Constraints | Description |
|---|---|---|---|
UserId |
uuid | PK | Unique identifier |
Email |
varchar(255) | UNIQUE, NOT NULL | Login email |
Username |
varchar(50) | UNIQUE, NOT NULL | Display username |
PasswordHash |
varchar(255) | NOT NULL | Hashed password |
DisplayName |
varchar(100) | NULL | Optional display name |
Bio |
text | NULL | Profile bio |
AvatarUrl |
varchar(500) | NULL | R2 URL for avatar |
Role |
smallint | NOT NULL, DEFAULT 0 | 0=User, 1=Moderator, 2=Admin |
EmailVerified |
boolean | NOT NULL, DEFAULT false | Email verification status |
DateEmailVerified |
timestamptz | NULL | When email was verified |
IsActive |
boolean | NOT NULL, DEFAULT true | Account active status |
DateLastLogin |
timestamptz | NULL | Last successful login |
DatePasswordChanged |
timestamptz | NULL | When password was last changed |
FailedLoginAttempts |
int | NOT NULL, DEFAULT 0 | Failed login attempt count |
LockoutEndTime |
timestamptz | NULL | Account lockout expiration |
UnlockToken |
varchar(128) | NULL | Token to unlock account via email |
UnlockTokenExpiry |
timestamptz | NULL | Unlock token valid until |
OnboardingCompleted |
boolean | NOT NULL, DEFAULT false | Whether user completed welcome wizard |
DateOnboardingCompleted |
timestamptz | NULL | When onboarding was completed |
DateCreated |
timestamptz | NOT NULL | Registration time |
DateUpdated |
timestamptz | NOT NULL | Last update time |
Indexes:
IX_User_Email(unique)IX_User_Username(unique)IX_User_LockoutEndTime(for checking locked accounts)
Enum - UserRole:
public enum UserRole
{
User = 0,
Moderator = 1,
Admin = 2
}User preferences (1:1 with User).
| Column | Type | Constraints | Description |
|---|---|---|---|
UserId |
uuid | PK, FK → User | User reference |
MeasurementSystem |
smallint | NOT NULL, DEFAULT 0 | 0=Imperial, 1=Metric |
DateFormat |
smallint | NOT NULL, DEFAULT 0 | 0=MM/DD/YYYY, 1=DD/MM/YYYY, 2=YYYY-MM-DD |
TimeFormat |
smallint | NOT NULL, DEFAULT 0 | 0=12h, 1=24h |
Timezone |
varchar(50) | NOT NULL, DEFAULT 'UTC' | IANA timezone |
FirstDayOfWeek |
smallint | NOT NULL, DEFAULT 0 | 0=Sunday, 1=Monday |
ShowRelativeTimes |
boolean | NOT NULL, DEFAULT true | "3 days ago" vs absolute date |
TrackingMode |
smallint | NOT NULL, DEFAULT 0 | 0=Easy, 1=Advanced |
FontSize |
smallint | NOT NULL, DEFAULT 1 | 0=Small, 1=Normal, 2=Large |
Density |
smallint | NOT NULL, DEFAULT 1 | 0=Compact, 1=Comfortable |
DefaultHomeSection |
smallint | NOT NULL, DEFAULT 0 | 0=ActiveCycles, 1=Dashboard, 2=Gallery |
NotifyStageReminders |
boolean | NOT NULL, DEFAULT true | Email for stage reminders |
NotifyComments |
boolean | NOT NULL, DEFAULT true | Email for comments |
NotifyReplies |
boolean | NOT NULL, DEFAULT true | Email for replies |
NotifyUglyRocks |
boolean | NOT NULL, DEFAULT true | Email for votes |
NotifyRecipeCloned |
boolean | NOT NULL, DEFAULT true | Email when recipe is cloned |
QuietHoursEnabled |
boolean | NOT NULL, DEFAULT false | Enable quiet hours |
QuietHoursStart |
time | NULL | Start of quiet hours (e.g., 22:00) |
QuietHoursEnd |
time | NULL | End of quiet hours (e.g., 08:00) |
DigestFrequency |
smallint | NOT NULL, DEFAULT 0 | 0=Instant, 1=Daily |
PhotoUploadQuality |
smallint | NOT NULL, DEFAULT 1 | 0=DataSaver, 1=Balanced, 2=HighQuality |
AddWatermark |
boolean | NOT NULL, DEFAULT false | Add watermark to photos |
AutoFillFromLastRun |
boolean | NOT NULL, DEFAULT true | Auto-populate from previous stage |
DefaultPostVisibility |
smallint | NOT NULL, DEFAULT 0 | 0=Private, 1=Unlisted, 2=Public |
Theme |
varchar(50) | NOT NULL, DEFAULT 'obsidian' | Specimen-inspired theme name |
StageFieldVisibility |
jsonb | NULL | Which stage fields to show (null = all) |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
StageFieldVisibility JSON Schema:
{
"materials": true,
"cleaningRun": true,
"reminder": true,
"notes": true,
"photos": true,
"duration": true,
"fillLevel": true,
"waterLevel": true,
"plasticPellets": true,
"resultRating": true,
"resultSliders": false,
"issueFlags": true,
"lessonsLearned": true
}When null, all fields are shown. Users can toggle individual fields. TrackingMode = Easy populates this with a minimal set; TrackingMode = Advanced shows all fields.
Enums:
public enum MeasurementSystem { Imperial = 0, Metric = 1 }
public enum DateFormat { MMDDYYYY = 0, DDMMYYYY = 1, YYYYMMDD = 2 }
public enum TimeFormat { TwelveHour = 0, TwentyFourHour = 1 }
public enum FirstDayOfWeek { Sunday = 0, Monday = 1 }
public enum TrackingMode { Easy = 0, Advanced = 1 }
public enum FontSize { Small = 0, Normal = 1, Large = 2 }
public enum Density { Compact = 0, Comfortable = 1 }
public enum DefaultHomeSection { ActiveCycles = 0, Dashboard = 1, Gallery = 2 }
public enum DigestFrequency { Instant = 0, Daily = 1 }
public enum PhotoUploadQuality { DataSaver = 0, Balanced = 1, HighQuality = 2 }
public enum PostVisibility { Private = 0, Unlisted = 1, Public = 2 }Theme Values (string):
| Value | Name | Type |
|---|---|---|
obsidian |
Obsidian (default) | Dark |
lapis-lazuli |
Lapis Lazuli | Dark |
bumblebee-jasper |
Bumblebee Jasper | Dark |
malachite |
Malachite | Dark |
rose-quartz |
Rose Quartz | Dark |
tigers-eye |
Tiger's Eye | Dark |
snowflake-obsidian |
Snowflake Obsidian | Light |
See Design System - Themes for full color definitions.
A tumbling cycle tracking a batch of rocks.
| Column | Type | Constraints | Description |
|---|---|---|---|
CycleId |
uuid | PK | Unique identifier |
UserId |
uuid | FK → User, NOT NULL | Owner |
Name |
varchar(255) | NOT NULL | Cycle name |
StartDate |
date | NOT NULL | User-set start date |
EndDate |
date | NULL | Completion date |
Status |
smallint | NOT NULL, DEFAULT 0 | 0=Active, 1=Completed |
Goal |
varchar(255) | NULL | "Nice polish", "Shaping only", "Test run" |
DifficultyRating |
smallint | NULL | 1-5 difficulty (set by user or auto) |
FinalQuality |
smallint | NULL | 1-5 rating when completed |
AdditionalSpecimens |
text | NULL | Free-text specimens |
Notes |
text | NULL | User notes |
IsDeleted |
boolean | NOT NULL, DEFAULT false | Soft delete |
DateDeleted |
timestamptz | NULL | When deleted |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_Cycle_UserIdIX_Cycle_StatusIX_Cycle_UserId_Status(composite)IX_Cycle_StartDate(for date-range queries)
Enum - CycleStatus:
public enum CycleStatus
{
Active = 0,
Completed = 1
}Junction table for Cycle ↔ Specimen (many-to-many).
| Column | Type | Constraints | Description |
|---|---|---|---|
CycleId |
uuid | PK, FK → Cycle | Cycle reference |
SpecimenId |
uuid | PK, FK → Specimen | Specimen reference |
DateCreated |
timestamptz | NOT NULL | When added |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
- Primary key on (
CycleId,SpecimenId) IX_CycleSpecimen_SpecimenId
Reference data for rock/mineral types.
| Column | Type | Constraints | Description |
|---|---|---|---|
SpecimenId |
uuid | PK | Unique identifier |
CommonName |
varchar(100) | NOT NULL | Primary name |
ScientificName |
varchar(100) | NULL | Scientific name |
Alias |
varchar(255) | NULL | Alternative names (comma-separated) |
RockFamily |
varchar(100) | NULL | Rock family |
Species |
varchar(100) | NULL | Species |
Variety |
varchar(100) | NULL | Variety |
MaterialType |
smallint | NOT NULL, DEFAULT 0 | 0=Rock, 1=Mineral, 2=Glass, 3=Fossil, 4=Other |
MohsHardnessMin |
decimal(3,1) | NULL | Minimum hardness |
MohsHardnessMax |
decimal(3,1) | NULL | Maximum hardness |
TumblingDifficulty |
smallint | NULL | 0=Easy, 1=Medium, 2=Hard |
RecommendedGritSequence |
text | NULL | Recommended grit sequence |
SpecialConsiderations |
text | NULL | Special handling notes |
Notes |
text | NULL | General notes |
IsActive |
boolean | NOT NULL, DEFAULT true | Available for selection |
UserCreated |
uuid | FK → User, NOT NULL | User who created |
UserUpdated |
uuid | FK → User, NOT NULL | User who last updated |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_Specimen_CommonNameIX_Specimen_IsActiveIX_Specimen_UserCreatedIX_Specimen_UserUpdated
Enums:
public enum MaterialType { Rock = 0, Mineral = 1, Glass = 2, Fossil = 3, Other = 4 }
public enum TumblingDifficulty { Easy = 0, Medium = 1, Hard = 2 }User-created custom specimen records. These are user-specific specimens that can be based on system specimens or created entirely custom.
| Column | Type | Constraints | Description |
|---|---|---|---|
UserSpecimenId |
uuid | PK | Unique identifier |
UserId |
uuid | FK → User, NOT NULL | Owner of this specimen |
CommonName |
varchar(100) | NOT NULL | Primary name |
ScientificName |
varchar(100) | NULL | Scientific name |
Alias |
varchar(255) | NULL | Alternative names |
RockFamily |
varchar(100) | NULL | Rock family |
Species |
varchar(100) | NULL | Species |
Variety |
varchar(100) | NULL | Variety |
MaterialType |
smallint | NOT NULL, DEFAULT 0 | 0=Rock, 1=Mineral, 2=Glass, 3=Fossil, 4=Other |
MohsHardnessMin |
decimal(3,1) | NULL | Minimum hardness |
MohsHardnessMax |
decimal(3,1) | NULL | Maximum hardness |
TumblingDifficulty |
smallint | NULL | 0=Easy, 1=Medium, 2=Hard |
RecommendedGritSequence |
text | NULL | Recommended grit sequence |
SpecialConsiderations |
text | NULL | Special handling notes |
Notes |
text | NULL | User notes |
IsPublic |
boolean | NOT NULL, DEFAULT false | Visible to other users |
BasedOnSpecimenId |
uuid | FK → Specimen, NULL | System specimen this is based on |
AiConfidenceScore |
smallint | NULL | AI lookup confidence 0-100% |
AiIsKnownSpecimen |
boolean | NULL | AI determined if specimen is known |
IsDeleted |
boolean | NOT NULL, DEFAULT false | Soft delete |
DateDeleted |
timestamptz | NULL | When deleted |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_UserSpecimen_UserIdIX_UserSpecimen_CommonNameIX_UserSpecimen_BasedOnSpecimenId
AI Metadata Fields:
AiConfidenceScore: When a user creates a specimen via AI lookup, this stores the confidence percentage (0-100) returned by Google GeminiAiIsKnownSpecimen: Indicates whether the AI recognized this as an established rock/mineral type (true) vs. a trade name, locality name, or unknown specimen (false)
Usage:
- Users can create custom specimens for rocks not in the system database
- Custom specimens appear in specimen pickers alongside system specimens
- AI lookup populates fields automatically with confidence indication
BasedOnSpecimenIdlinks to a system specimen if the user modified/copied one
A single stage execution within a cycle.
| Column | Type | Constraints | Description |
|---|---|---|---|
StageRunId |
uuid | PK | Unique identifier |
CycleId |
uuid | FK → Cycle, NOT NULL | Parent cycle |
BarrelId |
uuid | FK → Barrel, NOT NULL | Barrel used for this stage |
StageName |
varchar(100) | NOT NULL | e.g., "Stage 1", "Coarse Grind" |
StartDateTime |
timestamptz | NOT NULL | When stage started |
DurationDays |
int | NOT NULL, DEFAULT 0 | Duration in days |
DurationHours |
int | NOT NULL, DEFAULT 0 | Additional hours |
EndDateTime |
timestamptz | NOT NULL | Calculated end time |
Status |
smallint | NOT NULL, DEFAULT 0 | 0=Active, 1=Completed |
ReminderEnabled |
boolean | NOT NULL, DEFAULT false | Reminder on/off |
DateReminderSent |
timestamptz | NULL | When reminder was sent |
RemindAfterDays |
int | NULL | Days after start to send reminder |
RemindAtEndOfStage |
boolean | NULL | Send reminder at calculated end time |
LoadWeightBeforeGrams |
decimal(10,2) | NULL | Weight of rocks before stage (grams) |
LoadWeightAfterGrams |
decimal(10,2) | NULL | Weight of rocks after stage (grams) |
BarrelRpm |
decimal(6,2) | NULL | Barrel RPM (rotary tumblers) |
IsRpmEstimated |
boolean | NULL | Whether RPM is estimated vs measured |
FillLevelPercent |
smallint | NULL | % of barrel full (0-100) |
WaterLevel |
smallint | NULL | 0=JustCovering, 1=Halfway, 2=ThreeQuarters, 3=Full |
ResultRating |
smallint | NULL | 1-5 star satisfaction rating |
ResultShapeRounding |
smallint | NULL | 0-100 quality slider (advanced) |
ResultScratchLevel |
smallint | NULL | 0-100 quality slider (advanced) |
ResultPitting |
smallint | NULL | 0-100 quality slider (advanced) |
ResultShine |
smallint | NULL | 0-100 quality slider (advanced) |
IssueScratches |
boolean | NULL | Problem flag: scratches |
IssueChips |
boolean | NULL | Problem flag: chips/bruises |
IssueUnderRounded |
boolean | NULL | Problem flag: under-rounded |
IssueContamination |
boolean | NULL | Problem flag: grit contamination |
LessonsLearned |
text | NULL | What went wrong / lessons learned |
NextAction |
smallint | NULL | 0=Advance, 1=Repeat, 2=Abort |
Notes |
text | NULL | User notes |
IsDeleted |
boolean | NOT NULL, DEFAULT false | Soft delete |
DateDeleted |
timestamptz | NULL | When deleted |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_StageRun_CycleIdIX_StageRun_BarrelIdIX_StageRun_StatusIX_StageRun_EndDateTime(for reminder queries)
Notes:
LoadWeightBeforeGrams/LoadWeightAfterGrams: Stored in grams, displayed per user'sMeasurementSystempreference (oz for Imperial, g for Metric)RemindAfterDaysandRemindAtEndOfStageare mutually exclusive reminder options
Calculated Fields (not stored in DB):
RunNumber- Calculated on read: counts active (non-deleted) stages with sameStageNamewithin the cycle, ordered byDateCreated. Example: "Stage 1 Run 2" means second active run of "Stage 1". Recalculates automatically when stages are deleted.
Enum - StageRunStatus:
public enum StageRunStatus
{
Active = 0,
Completed = 1
}Enum - WaterLevel:
public enum WaterLevel
{
JustCovering = 0, // Water just covering rocks
Halfway = 1, // Barrel half full
ThreeQuarters = 2, // Barrel 3/4 full
Full = 3 // Barrel full
}Enum - StageNextAction:
public enum StageNextAction
{
Advance = 0, // Move to next stage
Repeat = 1, // Repeat same stage
Abort = 2 // Stop/scrap/re-cut
}Optional cleaning/burnish run attached to a stage (0 or 1 per stage).
| Column | Type | Constraints | Description |
|---|---|---|---|
CleaningRunId |
uuid | PK | Unique identifier |
StageRunId |
uuid | FK → StageRun, UNIQUE, NOT NULL | Parent stage |
DurationMinutes |
int | NOT NULL | Total duration in minutes |
Purpose |
smallint | NULL | 0=PostStageClean, 1=PrePolishClean, 2=FinalBurnish, 3=GritRemoval |
Status |
smallint | NOT NULL, DEFAULT 0 | 0=Active, 1=Completed |
ReminderEnabled |
boolean | NOT NULL, DEFAULT false | Reminder on/off |
DateReminderSent |
timestamptz | NULL | When reminder was sent |
ResultNotes |
text | NULL | Results: haze removal, contamination issues |
Notes |
text | NULL | User notes |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_CleaningRun_StageRunId(unique)
Cascade Delete: When a StageRun is soft-deleted, its CleaningRun and CleaningMaterials are cascade-deleted (hard delete) since they have no independent meaning.
Enum - CleaningRunStatus:
public enum CleaningRunStatus
{
Active = 0,
Completed = 1
}Enum - CleaningPurpose:
public enum CleaningPurpose
{
PostStageClean = 0, // Post-stage grit removal
PrePolishClean = 1, // Pre-polish cleaning
FinalBurnish = 2, // Final burnish before photos
GritRemoval = 3 // General grit removal
}Reference data for tumbling materials.
| Column | Type | Constraints | Description |
|---|---|---|---|
MaterialId |
uuid | PK | Unique identifier |
CommonName |
varchar(100) | NOT NULL | Display name |
Category |
smallint | NOT NULL | 0=Abrasive, 1=Additive, 2=Media, 3=Cleaning |
MaterialType |
varchar(100) | NULL | e.g., Silicon Carbide |
MaterialSize |
varchar(50) | NULL | e.g., 60/90, 120/220 |
UsageType |
smallint | NULL | 0=Coarse, 1=Medium, 2=Fine, 3=PrePolish, 4=Polish, 5=Cleaning |
MeshSize |
int | NOT NULL, DEFAULT 0 | Numeric mesh (0 for non-grit) |
SortOrder |
int | NOT NULL, DEFAULT 0 | Display order |
IsCleaning |
boolean | NOT NULL, DEFAULT false | Available for cleaning runs |
IsActive |
boolean | NOT NULL, DEFAULT true | Available for selection |
Notes |
text | NULL | Usage notes |
UserCreated |
uuid | FK → User, NOT NULL | User who created |
UserUpdated |
uuid | FK → User, NOT NULL | User who last updated |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_Material_CategoryIX_Material_IsActiveIX_Material_IsCleaningIX_Material_UserCreatedIX_Material_UserUpdated
Enums:
public enum MaterialCategory { Abrasive = 0, Additive = 1, Media = 2, Cleaning = 3 }
public enum UsageType { Coarse = 0, Medium = 1, Fine = 2, PrePolish = 3, Polish = 4, Cleaning = 5 }Materials used in a stage run (up to 10 per stage). Implements IWeightable.
| Column | Type | Constraints | Description |
|---|---|---|---|
StageMaterialId |
uuid | PK | Unique identifier |
StageRunId |
uuid | FK → StageRun, NOT NULL | Parent stage |
MaterialId |
uuid | FK → Material, NOT NULL | Material reference |
DisplayAmount |
decimal(10,2) | NULL | User-entered amount |
DisplayUnit |
varchar(20) | NULL | User's unit (tsp, tbsp, g, etc.) |
AmountGrams |
decimal(10,2) | NULL | Canonical amount (solids) |
AmountMilliliters |
decimal(10,2) | NULL | Canonical amount (liquids) |
SortOrder |
int | NOT NULL, DEFAULT 0 | Display order |
Notes |
text | NULL | Material-specific notes |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_StageMaterial_StageRunIdIX_StageMaterial_MaterialId
Materials used in a cleaning run (up to 5 per cleaning run). Implements IWeightable.
| Column | Type | Constraints | Description |
|---|---|---|---|
CleaningMaterialId |
uuid | PK | Unique identifier |
CleaningRunId |
uuid | FK → CleaningRun, NOT NULL | Parent cleaning run |
MaterialId |
uuid | FK → Material, NOT NULL | Material reference |
DisplayAmount |
decimal(10,2) | NULL | User-entered amount |
DisplayUnit |
varchar(20) | NULL | User's unit |
AmountGrams |
decimal(10,2) | NULL | Canonical amount (solids) |
AmountMilliliters |
decimal(10,2) | NULL | Canonical amount (liquids) |
SortOrder |
int | NOT NULL, DEFAULT 0 | Display order |
Notes |
text | NULL | Material-specific notes |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_CleaningMaterial_CleaningRunIdIX_CleaningMaterial_MaterialId
Seed table of known tumbler models with default specifications. Used for auto-fill during onboarding and tumbler creation.
| Column | Type | Constraints | Description |
|---|---|---|---|
TumblerModelId |
uuid | PK | Unique identifier |
Brand |
varchar(100) | NOT NULL | Manufacturer (e.g., "Lortone", "National Geographic") |
Model |
varchar(100) | NOT NULL | Model name (e.g., "3A", "QT6", "Custom") |
TumblerType |
smallint | NOT NULL | 0=Rotary, 1=Vibratory |
DefaultCapacityLbs |
decimal(5,2) | NULL | Default barrel capacity in lbs (NULL for custom) |
DefaultBarrelCount |
int | NOT NULL, DEFAULT 1 | Number of barrels included |
IsCustomEntry |
boolean | NOT NULL, DEFAULT false | True for "Generic/Custom" entries |
SortOrder |
int | NOT NULL, DEFAULT 100 | Display order (popular models first) |
IsActive |
boolean | NOT NULL, DEFAULT true | Available for selection |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_TumblerModel_BrandIX_TumblerModel_IsActive_SortOrder
Seed Data (see seed-data/tumbler-models-seed.csv):
- Popular brands: Lortone, National Geographic, Thumler's Tumbler, Harbor Freight, Chicago Electric
- Generic/Custom entries for DIY or unlisted tumblers
User's tumbler inventory. A tumbler has one or more barrels.
| Column | Type | Constraints | Description |
|---|---|---|---|
TumblerId |
uuid | PK | Unique identifier |
UserId |
uuid | FK → User, NULL | Owner (NULL for system tumblers) |
TumblerModelId |
uuid | FK → TumblerModel, NULL | Reference to known model (NULL for legacy/manual entry) |
Brand |
varchar(100) | NOT NULL | Manufacturer (auto-filled from model or manual) |
Model |
varchar(100) | NULL | Model name (auto-filled from model or manual) |
TumblerType |
smallint | NOT NULL | 0=Rotary, 1=Vibratory |
IsActive |
boolean | NOT NULL, DEFAULT true | Available for selection |
IsGeneric |
boolean | NOT NULL, DEFAULT false | System generic tumbler |
Notes |
text | NULL | User notes |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Relationships:
- Tumbler (1) → (many) Barrel
Indexes:
IX_Tumbler_UserIdIX_Tumbler_IsActiveIX_Tumbler_IsGeneric(for finding system tumblers)
Deletion Rules:
- If tumbler has no stage runs referencing its barrels → hard delete tumbler + barrels
- If tumbler has stage runs → cannot delete, only set
IsActive = false - System tumblers (
IsGeneric = true,UserId = NULL) → cannot be deleted by users
Enum - TumblerType:
public enum TumblerType
{
Rotary = 0,
Vibratory = 1
}Individual barrels belonging to a tumbler. Each barrel can be configured independently.
| Column | Type | Constraints | Description |
|---|---|---|---|
BarrelId |
uuid | PK | Unique identifier |
TumblerId |
uuid | FK → Tumbler, NOT NULL | Parent tumbler |
BarrelNumber |
int | NOT NULL | Position (1, 2, 3...) |
Nickname |
varchar(100) | NULL | Fun name (auto-populated from BarrelNickname, user can override) |
Capacity |
decimal(5,2) | NULL | Capacity value |
IsCapacityMetric |
boolean | NOT NULL, DEFAULT false | false=lbs, true=kg |
DefaultGritAmountGrams |
decimal(10,2) | NULL | Default grit amount (always stored in grams) |
IsDedicated |
boolean | NOT NULL, DEFAULT false | Is this barrel dedicated to specific stage(s)? |
DedicatedStages |
varchar(100)[] | NULL | Array of stage types: ['polish', 'burnish'] |
DateLastDeepClean |
date | NULL | Last barrel deep clean date |
ContaminationNotes |
text | NULL | Contamination history/issues |
IsActive |
boolean | NOT NULL, DEFAULT true | Available for selection |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Relationships:
- Barrel (many) → (1) Tumbler
- Barrel (1) → (many) StageRun
Indexes:
IX_Barrel_TumblerIdIX_Barrel_IsActive
Deletion Rules:
- If barrel has no stage runs → hard delete allowed
- If barrel has stage runs → cannot delete, only set
IsActive = false
How IsDedicated Works:
When IsDedicated = true, the DedicatedStages array specifies which stages this barrel is reserved for.
| Scenario | UI Behavior |
|---|---|
| User selects dedicated barrel for matching stage | Normal selection, no warning |
| User selects dedicated barrel for non-matching stage | Warning: "This barrel is dedicated to Polish/Burnish only. Using it for Coarse Grind may cause contamination. Continue anyway?" |
| User confirms warning | Proceed with selection, log the override |
| User cancels | Return to barrel selection |
Example:
Barrel: "Polly" (IsDedicated=true, DedicatedStages=['polish', 'burnish'])
User action: Selecting "Polly" for "Coarse Grind" stage
→ Warning displayed: "Polly is dedicated to polish, burnish stages only."
→ User can override if they know what they're doing
Grit Amount Calculation:
The DefaultGritAmountGrams is used to auto-populate the stage material form:
// When user selects this barrel for a stage:
if (barrel.DefaultGritAmountGrams != null) {
// Display in user's preferred unit (from UserSettings.MeasurementSystem)
if (user.MeasurementSystem == Metric) {
display = barrel.DefaultGritAmountGrams + " g";
} else {
display = (barrel.DefaultGritAmountGrams / 28.35) + " oz";
}
}
Seed table with fun barrel nicknames. Auto-assigned to new barrels.
| Column | Type | Constraints | Description |
|---|---|---|---|
NicknameId |
uuid | PK | Unique identifier |
Name |
varchar(100) | NOT NULL, UNIQUE | The nickname |
Category |
varchar(50) | NULL | Optional grouping (e.g., "Movies", "Food", "Animals") |
IsActive |
boolean | NOT NULL, DEFAULT true | Available for assignment |
UserCreated |
uuid | FK → User, NULL | User who created (NULL for seed data) |
UserUpdated |
uuid | FK → User, NULL | User who last updated |
DateCreated |
timestamptz | NOT NULL | When created |
DateUpdated |
timestamptz | NOT NULL | When last updated |
Indexes:
IX_BarrelNickname_Name(unique)IX_BarrelNickname_IsActive
Seed Data Examples (300 total):
| Name | Category |
|---|---|
| Rocky Balboa | Movies |
| The Grinder | Action |
| Barrel Roll | Gaming |
| Tumble Weed | Nature |
| Sir Spins-a-Lot | Royalty |
| The Polisher | Jobs |
| Grit Happens | Puns |
| Rolling Thunder | Weather |
| Spin Doctor | Music |
| The Rock Star | Music |
| Pebble Beach | Places |
| Stone Cold | Wrestling |
| Crystal Clear | Gems |
| Rough Diamond | Gems |
| Smooth Operator | Music |
| The Agitator | Action |
| Barrel of Laughs | Humor |
| Dizzy Gillespie | Music |
| Whirly Bird | Animals |
| The Mixer | Kitchen |
Auto-Assignment Logic:
// When creating a new barrel:
public async Task<string> AssignNicknameAsync(Guid tumblerId, int barrelNumber)
{
// Get nicknames already used by this tumbler's barrels
var usedNicknames = await _db.Barrels
.Where(b => b.TumblerId == tumblerId)
.Select(b => b.Nickname)
.ToListAsync();
// Get a random unused nickname
var nickname = await _db.BarrelNicknames
.Where(n => n.IsActive && !usedNicknames.Contains(n.Name))
.OrderBy(n => Guid.NewGuid()) // Random
.Select(n => n.Name)
.FirstOrDefaultAsync();
// Fallback if all nicknames used
return nickname ?? $"Barrel {barrelNumber}";
}Photos attached to stage runs.
| Column | Type | Constraints | Description |
|---|---|---|---|
PhotoId |
uuid | PK | Unique identifier |
StageRunId |
uuid | FK → StageRun, NOT NULL | Parent stage |
StorageKey |
varchar(500) | NOT NULL | R2 object key |
Url |
varchar(500) | NOT NULL | Public/signed URL |
FileName |
varchar(255) | NULL | Original filename |
MimeType |
varchar(50) | NOT NULL | e.g., image/jpeg |
FileSizeBytes |
bigint | NOT NULL | File size |
Width |
int | NULL | Image width in pixels |
Height |
int | NULL | Image height in pixels |
PhotoType |
smallint | NOT NULL | 0=Before, 1=During, 2=After |
SortOrder |
int | NOT NULL, DEFAULT 0 | Display order |
IsDeleted |
boolean | NOT NULL, DEFAULT false | Soft delete |
DateDeleted |
timestamptz | NULL | When deleted |
DateCreated |
timestamptz | NOT NULL | Upload time |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_Photo_StageRunIdIX_Photo_PhotoType
Enum - PhotoType:
public enum PhotoType
{
Before = 0,
During = 1,
After = 2
}Public posts shared to the gallery.
| Column | Type | Constraints | Description |
|---|---|---|---|
PostId |
uuid | PK | Unique identifier |
UserId |
uuid | FK → User, NOT NULL | Author |
CycleId |
uuid | FK → Cycle, NULL | Source cycle (optional) |
Title |
varchar(255) | NOT NULL | Post title |
Body |
text | NULL | User commentary |
Status |
smallint | NOT NULL, DEFAULT 0 | 0=Active, 1=Hidden |
IsEdited |
boolean | NOT NULL, DEFAULT false | Has been edited |
UglyRocksCount |
int | NOT NULL, DEFAULT 0 | Denormalized vote count |
CommentCount |
int | NOT NULL, DEFAULT 0 | Denormalized comment count |
IsDeleted |
boolean | NOT NULL, DEFAULT false | Soft delete |
DateDeleted |
timestamptz | NULL | When deleted |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_Post_UserIdIX_Post_CycleIdIX_Post_StatusIX_Post_DateCreatedIX_Post_UglyRocksCountIX_Post_Status_DateCreated(composite for gallery sorting)
Enum - PostStatus:
public enum PostStatus
{
Active = 0,
Hidden = 1
}Junction table for Post ↔ Photo (photos selected for a post).
| Column | Type | Constraints | Description |
|---|---|---|---|
PostId |
uuid | PK, FK → Post | Post reference |
PhotoId |
uuid | PK, FK → Photo | Photo reference |
SortOrder |
int | NOT NULL, DEFAULT 0 | Display order in post |
DateCreated |
timestamptz | NOT NULL | When added |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
- Primary key on (
PostId,PhotoId) IX_PostPhoto_PhotoId
Comments on public posts.
| Column | Type | Constraints | Description |
|---|---|---|---|
CommentId |
uuid | PK | Unique identifier |
PostId |
uuid | FK → Post, NOT NULL | Parent post |
UserId |
uuid | FK → User, NOT NULL | Author |
ParentCommentId |
uuid | FK → Comment, NULL | Parent comment (for replies) |
Body |
text | NOT NULL | Comment text |
IsEdited |
boolean | NOT NULL, DEFAULT false | Has been edited |
IsHidden |
boolean | NOT NULL, DEFAULT false | Hidden by moderator |
IsDeleted |
boolean | NOT NULL, DEFAULT false | Soft delete |
DateDeleted |
timestamptz | NULL | When deleted |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_Comment_PostIdIX_Comment_UserIdIX_Comment_ParentCommentIdIX_Comment_DateCreated
User reports on comments for moderation.
| Column | Type | Constraints | Description |
|---|---|---|---|
CommentReportId |
uuid | PK | Unique identifier |
CommentId |
uuid | FK → Comment, NOT NULL | Reported comment |
ReporterId |
uuid | FK → User, NOT NULL | User who reported |
Reason |
text | NULL | Optional reason |
Status |
smallint | NOT NULL, DEFAULT 0 | 0=Pending, 1=Reviewed, 2=Dismissed |
ReviewedById |
uuid | FK → User, NULL | Moderator who reviewed |
DateReviewed |
timestamptz | NULL | When reviewed |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_CommentReport_CommentIdIX_CommentReport_Status
Unique constraint: One report per user per comment
UQ_CommentReport_CommentId_ReporterId
Enum - ReportStatus:
public enum ReportStatus
{
Pending = 0,
Reviewed = 1,
Dismissed = 2
}Upvotes ("Ugly Rocks") on posts.
| Column | Type | Constraints | Description |
|---|---|---|---|
VoteId |
uuid | PK | Unique identifier |
PostId |
uuid | FK → Post, NOT NULL | Voted post |
UserId |
uuid | FK → User, NOT NULL | Voter |
DateCreated |
timestamptz | NOT NULL | When voted |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_Vote_PostIdIX_Vote_UserId
Unique constraint: One vote per user per post
UQ_Vote_PostId_UserId
User suggestions for new specimens.
| Column | Type | Constraints | Description |
|---|---|---|---|
SpecimenSuggestionId |
uuid | PK | Unique identifier |
UserId |
uuid | FK → User, NOT NULL | Suggester |
CommonName |
varchar(100) | NOT NULL | Suggested name |
Details |
text | NULL | Additional info |
Status |
smallint | NOT NULL, DEFAULT 0 | 0=Pending, 1=Approved, 2=Rejected |
ReviewedById |
uuid | FK → User, NULL | Moderator who reviewed |
DateReviewed |
timestamptz | NULL | When reviewed |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_SpecimenSuggestion_StatusIX_SpecimenSuggestion_UserId
Audit trail for admin/mod actions.
| Column | Type | Constraints | Description |
|---|---|---|---|
AuditLogId |
uuid | PK | Unique identifier |
UserId |
uuid | FK → User, NOT NULL | User who performed action |
Action |
varchar(100) | NOT NULL | Action type |
EntityType |
varchar(100) | NOT NULL | Affected entity type |
EntityId |
uuid | NULL | Affected entity ID |
Details |
jsonb | NULL | Additional details |
IpAddress |
varchar(45) | NULL | Client IP |
DateCreated |
timestamptz | NOT NULL | When action occurred |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_AuditLog_UserIdIX_AuditLog_EntityType_EntityIdIX_AuditLog_DateCreated
Tokens for email verification flow.
| Column | Type | Constraints | Description |
|---|---|---|---|
EmailVerificationTokenId |
uuid | PK | Unique identifier |
UserId |
uuid | FK → User, NOT NULL | User being verified |
Token |
varchar(255) | UNIQUE, NOT NULL | Verification token (hashed) |
DateExpires |
timestamptz | NOT NULL | Token expiration time |
DateUsed |
timestamptz | NULL | When token was used |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_EmailVerificationToken_Token(unique)IX_EmailVerificationToken_UserIdIX_EmailVerificationToken_DateExpires
Notes: Tokens should be deleted after use or expiration via background job.
Tokens for password reset flow.
| Column | Type | Constraints | Description |
|---|---|---|---|
PasswordResetTokenId |
uuid | PK | Unique identifier |
UserId |
uuid | FK → User, NOT NULL | User resetting password |
Token |
varchar(255) | UNIQUE, NOT NULL | Reset token (hashed) |
DateExpires |
timestamptz | NOT NULL | Token expiration time |
DateUsed |
timestamptz | NULL | When token was used |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_PasswordResetToken_Token(unique)IX_PasswordResetToken_UserIdIX_PasswordResetToken_DateExpires
Notes: Tokens should be deleted after use or expiration via background job. Consider invalidating all existing tokens when a new one is requested.
JWT refresh tokens for maintaining sessions.
| Column | Type | Constraints | Description |
|---|---|---|---|
RefreshTokenId |
uuid | PK | Unique identifier |
UserId |
uuid | FK → User, NOT NULL | Token owner |
Token |
varchar(255) | UNIQUE, NOT NULL | Refresh token (hashed) |
DateExpires |
timestamptz | NOT NULL | Token expiration time |
IsRevoked |
boolean | NOT NULL, DEFAULT false | Manually revoked |
DateRevoked |
timestamptz | NULL | When revoked |
ReplacedByTokenId |
uuid | FK → RefreshToken, NULL | Token that replaced this one |
DeviceInfo |
varchar(255) | NULL | Browser/device identifier |
IpAddress |
varchar(45) | NULL | Client IP at creation |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_RefreshToken_Token(unique)IX_RefreshToken_UserIdIX_RefreshToken_DateExpires
Notes: Implements refresh token rotation - when a token is used, it's revoked and replaced with a new one. Cleanup job removes expired/revoked tokens.
User reports on posts for moderation.
| Column | Type | Constraints | Description |
|---|---|---|---|
PostReportId |
uuid | PK | Unique identifier |
PostId |
uuid | FK → Post, NOT NULL | Reported post |
ReporterId |
uuid | FK → User, NOT NULL | User who reported |
Reason |
text | NULL | Optional reason |
Status |
smallint | NOT NULL, DEFAULT 0 | 0=Pending, 1=Reviewed, 2=Dismissed |
ReviewedById |
uuid | FK → User, NULL | Moderator who reviewed |
DateReviewed |
timestamptz | NULL | When reviewed |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_PostReport_PostIdIX_PostReport_Status
Unique constraint: One report per user per post
UQ_PostReport_PostId_ReporterId
User suggestions for new materials.
| Column | Type | Constraints | Description |
|---|---|---|---|
MaterialSuggestionId |
uuid | PK | Unique identifier |
UserId |
uuid | FK → User, NOT NULL | Suggester |
CommonName |
varchar(100) | NOT NULL | Suggested name |
Category |
smallint | NULL | Suggested category |
Details |
text | NULL | Additional info |
Status |
smallint | NOT NULL, DEFAULT 0 | 0=Pending, 1=Approved, 2=Rejected |
ReviewedById |
uuid | FK → User, NULL | Moderator who reviewed |
DateReviewed |
timestamptz | NULL | When reviewed |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_MaterialSuggestion_StatusIX_MaterialSuggestion_UserId
Scheduled reminders for stage completion notifications.
| Column | Type | Constraints | Description |
|---|---|---|---|
ScheduledReminderId |
uuid | PK | Unique identifier |
UserId |
uuid | FK → User, NOT NULL | User to notify |
StageRunId |
uuid | FK → StageRun, NULL | Associated stage (null if cleaning) |
CleaningRunId |
uuid | FK → CleaningRun, NULL | Associated cleaning run |
ReminderType |
smallint | NOT NULL | 0=StageComplete, 1=CleaningComplete |
DateScheduledFor |
timestamptz | NOT NULL | When to send reminder |
Status |
smallint | NOT NULL, DEFAULT 0 | 0=Pending, 1=Sent, 2=Cancelled |
DateSent |
timestamptz | NULL | When reminder was sent |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_ScheduledReminder_DateScheduledFor_Status(composite for job queries)IX_ScheduledReminder_StageRunIdIX_ScheduledReminder_CleaningRunIdIX_ScheduledReminder_UserId
Enums:
public enum ReminderType
{
StageComplete = 0,
CleaningComplete = 1
}
public enum ReminderStatus
{
Pending = 0,
Sent = 1,
Cancelled = 2
}Notes: Background job queries for Status = Pending AND ScheduledFor <= NOW(). When stage/cleaning run is deleted, associated reminders are cancelled.
User-saved recipe templates for reuse.
| Column | Type | Constraints | Description |
|---|---|---|---|
TemplateId |
uuid | PK | Unique identifier |
UserId |
uuid | FK → User, NOT NULL | Owner |
Name |
varchar(255) | NOT NULL | Template name |
Description |
text | NULL | Description of when to use |
SourceCycleId |
uuid | FK → Cycle, NULL | Cycle this was created from |
SourcePostId |
uuid | FK → Post, NULL | Public post this was cloned from |
RockTypes |
text | NULL | Rock types this works for |
Goal |
varchar(255) | NULL | Target goal |
DifficultyRating |
smallint | NULL | 1-5 difficulty |
StageData |
jsonb | NOT NULL | Stage definitions (name, duration, materials) |
IsActive |
boolean | NOT NULL, DEFAULT true | Available for use |
UsageCount |
int | NOT NULL, DEFAULT 0 | Times used |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_Template_UserIdIX_Template_IsActive
StageData JSON Schema:
{
"stages": [
{
"stageName": "Stage 1 - Coarse Grind",
"durationDays": 7,
"durationHours": 0,
"materials": [
{ "materialId": "uuid", "amountGrams": 30, "unit": "tbsp" }
],
"fillLevelPercent": 75,
"plasticPelletsUsed": true,
"plasticPelletsPercent": 20,
"notes": "Check after 5 days"
}
]
}Notes:
- Templates can be created from completed cycles ("Save as Template")
- Templates can be cloned from public posts ("Use as Template")
- When starting a new cycle, user can select a template to pre-populate stages
Orphan Reference Handling:
The StageData JSON contains materialId references to the Material table. If a Material is deleted:
- Prevention: Materials should only be soft-deleted (set
IsActive = false), never hard-deleted - Graceful degradation: When loading a template, check if referenced materials exist:
- If material exists but
IsActive = false: Show as "Material Name (discontinued)" with warning - If material doesn't exist: Show as "Unknown material" and allow user to substitute
- If material exists but
- Template validation: Add a
IsValidcomputed property that checks all materialIds still exist
// Example validation when loading template
foreach (var stage in template.StageData.Stages)
{
foreach (var material in stage.Materials)
{
var mat = await _db.Materials.FindAsync(material.MaterialId);
if (mat == null)
material.Status = "missing";
else if (!mat.IsActive)
material.Status = "discontinued";
else
material.Status = "active";
}
}Free-text tags for organizing cycles and posts.
| Column | Type | Constraints | Description |
|---|---|---|---|
TagId |
uuid | PK | Unique identifier |
UserId |
uuid | FK → User, NOT NULL | Owner (user-scoped tags) |
Name |
varchar(50) | NOT NULL | Tag name (e.g., "test", "kids", "gift") |
Color |
varchar(7) | NULL | Hex color (e.g., "#FF5733") |
DateCreated |
timestamptz | NOT NULL | |
DateUpdated |
timestamptz | NOT NULL |
Indexes:
IX_Tag_UserIdIX_Tag_UserId_Name(unique per user)
Notes: Tags are user-scoped. Each user can have their own set of tags.
Junction table for Cycle ↔ Tag (many-to-many).
| Column | Type | Constraints | Description |
|---|---|---|---|
CycleId |
uuid | PK, FK → Cycle | Cycle reference |
TagId |
uuid | PK, FK → Tag | Tag reference |
DateCreated |
timestamptz | NOT NULL | When added |
DateUpdated |
timestamptz | NOT NULL | When last updated |
Indexes:
- Primary key on (
CycleId,TagId) IX_CycleTag_TagId
Junction table for Post ↔ Tag (many-to-many).
| Column | Type | Constraints | Description |
|---|---|---|---|
PostId |
uuid | PK, FK → Post | Post reference |
TagId |
uuid | PK, FK → Tag | Tag reference |
DateCreated |
timestamptz | NOT NULL | When added |
DateUpdated |
timestamptz | NOT NULL | When last updated |
Indexes:
- Primary key on (
PostId,TagId) IX_PostTag_TagId
| Parent | Child | Relationship | FK Column |
|---|---|---|---|
| User | Cycle | 1:N | Cycle.UserId |
| User | UserSpecimen | 1:N | UserSpecimen.UserId |
| User | Tumbler | 1:N | Tumbler.UserId |
| User | Post | 1:N | Post.UserId |
| User | Comment | 1:N | Comment.UserId |
| User | Vote | 1:N | Vote.UserId |
| User | UserSettings | 1:1 | UserSettings.UserId |
| Cycle | StageRun | 1:N | StageRun.CycleId |
| Cycle | CycleSpecimen | 1:N | CycleSpecimen.CycleId |
| Cycle | Post | 1:N | Post.CycleId |
| Specimen | CycleSpecimen | 1:N | CycleSpecimen.SpecimenId |
| Specimen | UserSpecimen | 1:N | UserSpecimen.BasedOnSpecimenId |
| StageRun | CleaningRun | 1:0..1 | CleaningRun.StageRunId |
| StageRun | StageMaterial | 1:N | StageMaterial.StageRunId |
| StageRun | Photo | 1:N | Photo.StageRunId |
| CleaningRun | CleaningMaterial | 1:N | CleaningMaterial.CleaningRunId |
| Material | StageMaterial | 1:N | StageMaterial.MaterialId |
| Material | CleaningMaterial | 1:N | CleaningMaterial.MaterialId |
| TumblerModel | Tumbler | 1:N | Tumbler.TumblerModelId |
| Tumbler | Barrel | 1:N | Barrel.TumblerId |
| Barrel | StageRun | 1:N | StageRun.BarrelId |
| Post | Comment | 1:N | Comment.PostId |
| Post | Vote | 1:N | Vote.PostId |
| Post | PostPhoto | 1:N | PostPhoto.PostId |
| Photo | PostPhoto | 1:N | PostPhoto.PhotoId |
| Comment | Comment | 1:N (self) | Comment.ParentCommentId |
| Comment | CommentReport | 1:N | CommentReport.CommentId |
| User | EmailVerificationToken | 1:N | EmailVerificationToken.UserId |
| User | PasswordResetToken | 1:N | PasswordResetToken.UserId |
| User | RefreshToken | 1:N | RefreshToken.UserId |
| Post | PostReport | 1:N | PostReport.PostId |
| User | MaterialSuggestion | 1:N | MaterialSuggestion.UserId |
| User | ScheduledReminder | 1:N | ScheduledReminder.UserId |
| StageRun | ScheduledReminder | 1:N | ScheduledReminder.StageRunId |
| CleaningRun | ScheduledReminder | 1:N | ScheduledReminder.CleaningRunId |
| RefreshToken | RefreshToken | 1:0..1 (self) | RefreshToken.ReplacedByTokenId |
| User | Template | 1:N | Template.UserId |
| Cycle | Template | 1:N | Template.SourceCycleId |
| Post | Template | 1:N | Template.SourcePostId |
| User | Tag | 1:N | Tag.UserId |
| Cycle | CycleTag | 1:N | CycleTag.CycleId |
| Tag | CycleTag | 1:N | CycleTag.TagId |
| Post | PostTag | 1:N | PostTag.PostId |
| Tag | PostTag | 1:N | PostTag.TagId |
- Unique email and username per user
- One vote per user per post (
UQ_Vote_PostId_UserId) - One report per user per comment (
UQ_CommentReport_CommentId_ReporterId) - One report per user per post (
UQ_PostReport_PostId_ReporterId) - One cleaning run per stage run (unique
StageRunIdon CleaningRun) - Foreign key constraints on all relationships
- Cascade delete: StageRun → CleaningRun → CleaningMaterial
- Max 10 materials per stage run
- Max 5 materials per cleaning run
- Max 10 photos per stage run
- Password complexity requirements
- Email format validation
- Photo file size limits (20 MB)
- Hardness warning when specimen difference > 1.0
Entities/queries to cache:
| Key Pattern | Data | TTL |
|---|---|---|
specimens:all |
Active specimens list | 1 hour |
specimens:{id} |
Single specimen | 1 hour |
materials:all |
Active materials list | 1 hour |
materials:{id} |
Single material | 1 hour |
user:{id}:tumblers |
User's tumblers | 15 min |
user:{id}:settings |
User settings | 15 min |
Cache invalidation triggers:
- Specimen/Material create/update → invalidate list + item
- Tumbler create/update/delete → invalidate user's tumbler cache
- Settings update → invalidate user's settings cache
A special "System" user account is required for seed data and system-generated content.
| Field | Value |
|---|---|
UserId |
00000000-0000-0000-0000-000000000001 |
Email |
system@myuglyrocks.local |
Username |
system |
Role |
Admin (2) |
IsActive |
false (cannot login) |
Usage:
UserCreated/UserUpdatedfor seed Specimens and MaterialsUserCreated/UserUpdatedfor seed BarrelNicknames- System-generated tumblers (
Tumbler.IsGeneric = true)
Note: The system user has IsActive = false so it cannot be used to login. It exists only to satisfy NOT NULL constraints on IUserAuditable entities.
- System user (see 8.1)
- Generic Rotary Tumbler (system tumbler,
UserId = NULL,IsGeneric = true) - Generic Vibratory Tumbler (system tumbler,
UserId = NULL,IsGeneric = true) - 300 BarrelNicknames (with
UserCreated = system user) - Initial specimens list (with
UserCreated = system user) - Initial materials list (with
UserCreated = system user)
- Add indexes as listed above
- Consider partial indexes for soft-deleted queries (e.g.,
WHERE IsDeleted = false) - Monitor query performance and add indexes as needed
- Review data model for completeness
- Proceed to API Specification (
05-API-SPEC.md) - Or proceed to Architecture (
06-ARCHITECTURE.md)