cleanup
CI / Frontend (push) Successful in 11m7s
CI / Go - apps/auth-service (push) Failing after 8s
CI / Go - apps/backend (push) Failing after 2s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped

This commit is contained in:
Tomas Dvorak
2026-05-05 09:48:15 +02:00
parent 48c3e15a38
commit cf3315e8fc
155 changed files with 70334 additions and 19015 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.github
.env
.env.*
bin
coverage
tmp
*.log
Dockerfile
.dockerignore
+11 -8
View File
@@ -16,20 +16,23 @@ COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/auth-service ./cmd/api
# Final stage
FROM alpine:3.22
WORKDIR /app
# Install ca-certificates for HTTPS
RUN apk add --no-cache ca-certificates
RUN apk add --no-cache ca-certificates \
&& addgroup -S bookra \
&& adduser -S -D -H -u 10001 -G bookra bookra
# Copy binary from builder
COPY --from=builder /app/auth-service /app/
# Copy migrations
COPY --from=builder /app/migrations /app/migrations
COPY --from=builder --chown=bookra:bookra /app/auth-service /app/
COPY --from=builder --chown=bookra:bookra /app/migrations /app/migrations
ENV PORT=8080
EXPOSE 8080
USER bookra
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- "http://127.0.0.1:${PORT:-8080}/health" >/dev/null || exit 1
CMD ["/app/auth-service"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+10
View File
@@ -0,0 +1,10 @@
.git
.github
.env
.env.*
bin
coverage
tmp
*.log
Dockerfile
.dockerignore
+11 -3
View File
@@ -15,11 +15,19 @@ FROM alpine:3.22
WORKDIR /app
RUN apk add --no-cache ca-certificates
RUN apk add --no-cache ca-certificates \
&& addgroup -S bookra \
&& adduser -S -D -H -u 10001 -G bookra bookra
COPY --from=builder /app/backend /app/
COPY --from=builder /app/migrations /app/migrations
COPY --from=builder --chown=bookra:bookra /app/backend /app/
COPY --from=builder --chown=bookra:bookra /app/migrations /app/migrations
ENV PORT=8080
EXPOSE 8080
USER bookra
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- "http://127.0.0.1:${PORT:-8080}/healthz" >/dev/null || exit 1
CMD ["/app/backend"]
+20 -30
View File
@@ -13,6 +13,7 @@ import (
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/shared"
paddle "github.com/PaddleHQ/paddle-go-sdk/v5"
"github.com/jackc/pgx/v5"
@@ -95,11 +96,11 @@ func (s *Service) GetSubscription(ctx context.Context, principal domain.Principa
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
TenantID: membership.Tenant.ID,
BillingProvider: "paddle",
Status: firstNonEmpty(membership.Tenant.SubscriptionStatus, "inactive"),
PlanCode: normalizePlanCode(membership.Tenant.PlanCode),
Currency: "czk",
TenantID: membership.Tenant.ID,
BillingProvider: "paddle",
Status: firstNonEmpty(membership.Tenant.SubscriptionStatus, "inactive"),
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
Currency: "czk",
}, s.cfg), nil
}
return domain.SubscriptionSnapshot{}, err
@@ -155,11 +156,11 @@ func (s *Service) Refresh(ctx context.Context, principal domain.Principal) (doma
customerID := derefString(membership.Tenant.BillingCustomerID)
if customerID == "" {
return toSnapshot(membership.Tenant, db.BillingSnapshotRecord{
TenantID: membership.Tenant.ID,
BillingProvider: "paddle",
Status: "inactive",
PlanCode: normalizePlanCode(membership.Tenant.PlanCode),
Currency: "czk",
TenantID: membership.Tenant.ID,
BillingProvider: "paddle",
Status: "inactive",
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
Currency: "czk",
}, s.cfg), nil
}
if s.client == nil {
@@ -321,7 +322,7 @@ func (s *Service) syncPaddleData(ctx context.Context, tenant db.TenantRecord, cu
BillingCustomerID: customerID,
BillingSubscriptionID: "",
Status: "inactive",
PlanCode: normalizePlanCode(tenant.PlanCode),
PlanCode: shared.NormalizePlanCode(tenant.PlanCode),
Currency: "czk",
PriceID: "",
LastSyncedAt: &now,
@@ -352,9 +353,9 @@ func (s *Service) syncPaddleData(ctx context.Context, tenant db.TenantRecord, cu
func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg config.Config) domain.SubscriptionSnapshot {
if record.PlanCode == "" {
record.PlanCode = normalizePlanCode(tenant.PlanCode)
record.PlanCode = shared.NormalizePlanCode(tenant.PlanCode)
} else {
record.PlanCode = normalizePlanCode(record.PlanCode)
record.PlanCode = shared.NormalizePlanCode(record.PlanCode)
}
record.Currency = normalizeCurrency(record.Currency)
if record.Status == "" {
@@ -388,7 +389,7 @@ func toSnapshot(tenant db.TenantRecord, record db.BillingSnapshotRecord, cfg con
}
func entitlementsForPlan(planCode string) domain.PlanEntitlements {
switch normalizePlanCode(planCode) {
switch shared.NormalizePlanCode(planCode) {
case "starter":
return domain.PlanEntitlements{MaxLocations: 1, MaxStaff: 3, EmailReminders: true, AdvancedReporting: false, WidgetEmbedding: true, UmamiTracking: false}
case "business":
@@ -402,15 +403,15 @@ func (s *Service) planCodeForPrice(priceID string, fallback string) string {
for planCode, currencies := range s.cfg.PaddlePriceMatrix {
for _, configuredPriceID := range currencies {
if configuredPriceID != "" && configuredPriceID == priceID {
return normalizePlanCode(planCode)
return shared.NormalizePlanCode(planCode)
}
}
}
return normalizePlanCode(fallback)
return shared.NormalizePlanCode(fallback)
}
func (s *Service) priceForPlan(planCode string, currency string) (string, string, string) {
resolvedPlan := normalizePlanCode(strings.TrimSpace(planCode))
resolvedPlan := shared.NormalizePlanCode(strings.TrimSpace(planCode))
if resolvedPlan == "" {
resolvedPlan = "pro"
}
@@ -444,7 +445,7 @@ func subscriptionRank(subscription *paddle.Subscription) int {
}
func displayPricesForPlan(planCode string) []domain.PlanDisplayPrice {
switch normalizePlanCode(planCode) {
switch shared.NormalizePlanCode(planCode) {
case "starter":
return []domain.PlanDisplayPrice{
{Currency: "czk", AmountCents: 11900, Formatted: "119 Kc"},
@@ -463,17 +464,6 @@ func displayPricesForPlan(planCode string) []domain.PlanDisplayPrice {
}
}
func normalizePlanCode(planCode string) string {
switch strings.TrimSpace(planCode) {
case "growth":
return "pro"
case "multi-location":
return "business"
default:
return strings.TrimSpace(planCode)
}
}
func normalizeCurrency(currency string) string {
switch strings.ToLower(strings.TrimSpace(currency)) {
case "usd":
@@ -498,7 +488,7 @@ func checkoutAvailable(cfg config.Config, planCode string) bool {
if !cfg.PaddleConfigured() || !cfg.PaddleWebhookConfigured() {
return false
}
planCode = normalizePlanCode(planCode)
planCode = shared.NormalizePlanCode(planCode)
for _, priceID := range cfg.PaddlePriceMatrix[planCode] {
if strings.TrimSpace(priceID) != "" {
return true
+2 -12
View File
@@ -12,6 +12,7 @@ import (
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/notifications"
"bookra/apps/backend/internal/shared"
"github.com/jackc/pgx/v5"
)
@@ -335,7 +336,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
TenantSlug: membership.Tenant.Slug,
Locale: membership.Tenant.Locale,
Timezone: membership.Tenant.Timezone,
PlanCode: normalizePlanCode(membership.Tenant.PlanCode),
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
PublicBookingURL: "/book/" + membership.Tenant.Slug,
SetupCompletion: 100,
KPIs: []domain.DashboardKPI{
@@ -366,17 +367,6 @@ func trackingStatus(repo db.Repository, ctx context.Context, tenant db.TenantRec
return domain.TrackingStatus{Provider: "umami", Connected: true, SiteID: brand.UmamiSiteID, Message: "Umami tracking is connected."}
}
func normalizePlanCode(planCode string) string {
switch planCode {
case "growth":
return "pro"
case "multi-location":
return "business"
default:
return planCode
}
}
func generateAppointmentSlots(
tenant db.TenantRecord,
services []db.ServiceRecord,
+3 -12
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"os"
"strings"
"bookra/apps/backend/internal/shared"
)
type Config struct {
@@ -112,7 +114,7 @@ func (cfg Config) PaddleWebhookConfigured() bool {
}
func (cfg Config) PaddleCheckoutConfigured(planCode string) bool {
planCode = normalizePlanCode(planCode)
planCode = shared.NormalizePlanCode(planCode)
return cfg.PaddleConfigured() && cfg.PaddleWebhookConfigured() && cfg.PaddlePriceMatrix[planCode]["czk"] != "" && cfg.PaddlePriceMatrix[planCode]["usd"] != ""
}
@@ -139,17 +141,6 @@ func normalizePaddleEnvironment(value string) string {
}
}
func normalizePlanCode(planCode string) string {
switch strings.TrimSpace(planCode) {
case "growth":
return "pro"
case "multi-location":
return "business"
default:
return strings.TrimSpace(planCode)
}
}
func valueOrDefault(key string, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
+72
View File
@@ -0,0 +1,72 @@
package db
import (
"context"
)
func (r *PGRepository) GetSubscriptionSnapshot(ctx context.Context, tenantID string) (BillingSnapshotRecord, error) {
var record BillingSnapshotRecord
err := r.pool.QueryRow(ctx, `
SELECT tenant_id, billing_provider, billing_customer_id, billing_subscription_id, status, plan_code, COALESCE(currency, 'czk'), price_id,
cancel_at_period_end, current_period_start, current_period_end,
payment_method_brand, payment_method_last4, last_synced_at
FROM billing_snapshots
WHERE tenant_id = $1
`, tenantID).Scan(
&record.TenantID,
&record.BillingProvider,
&record.BillingCustomerID,
&record.BillingSubscriptionID,
&record.Status,
&record.PlanCode,
&record.Currency,
&record.PriceID,
&record.CancelAtPeriodEnd,
&record.CurrentPeriodStart,
&record.CurrentPeriodEnd,
&record.PaymentMethodBrand,
&record.PaymentMethodLast4,
&record.LastSyncedAt,
)
return record, err
}
func (r *PGRepository) UpsertSubscriptionSnapshot(ctx context.Context, params BillingSnapshotRecord) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO billing_snapshots (
tenant_id, billing_provider, billing_customer_id, billing_subscription_id, status, plan_code, currency, price_id,
cancel_at_period_end, current_period_start, current_period_end,
payment_method_brand, payment_method_last4, last_synced_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
ON CONFLICT (tenant_id) DO UPDATE SET
billing_provider = EXCLUDED.billing_provider,
billing_customer_id = EXCLUDED.billing_customer_id,
billing_subscription_id = EXCLUDED.billing_subscription_id,
status = EXCLUDED.status,
plan_code = EXCLUDED.plan_code,
currency = EXCLUDED.currency,
price_id = EXCLUDED.price_id,
cancel_at_period_end = EXCLUDED.cancel_at_period_end,
current_period_start = EXCLUDED.current_period_start,
current_period_end = EXCLUDED.current_period_end,
payment_method_brand = EXCLUDED.payment_method_brand,
payment_method_last4 = EXCLUDED.payment_method_last4,
last_synced_at = EXCLUDED.last_synced_at,
updated_at = now()
`, params.TenantID, firstNonEmpty(params.BillingProvider, "paddle"), params.BillingCustomerID, params.BillingSubscriptionID, params.Status, params.PlanCode,
firstNonEmpty(params.Currency, "czk"), params.PriceID, params.CancelAtPeriodEnd, params.CurrentPeriodStart, params.CurrentPeriodEnd,
params.PaymentMethodBrand, params.PaymentMethodLast4, params.LastSyncedAt)
return err
}
func (r *PGRepository) RecordBillingEvent(ctx context.Context, tenantID string, provider string, eventID string, eventType string, payload []byte) (bool, error) {
result, err := r.pool.Exec(ctx, `
INSERT INTO subscription_events (tenant_id, billing_provider, billing_provider_event_id, event_type, payload, processed_at)
VALUES ($1, $2, $3, $4, $5::jsonb, now())
ON CONFLICT (billing_provider, billing_provider_event_id) DO NOTHING
`, tenantID, firstNonEmpty(provider, "paddle"), eventID, eventType, payload)
if err != nil {
return false, err
}
return result.RowsAffected() == 1, nil
}
+173
View File
@@ -0,0 +1,173 @@
package db
import (
"context"
"time"
)
func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
customer_name, customer_email, starts_at, ends_at, status, reference
FROM bookings
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
ORDER BY starts_at ASC
`, tenantID, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
var records []BookingRecord
for rows.Next() {
var record BookingRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.ServiceID,
&record.ClassSessionID,
&record.StaffID,
&record.LocationID,
&record.CustomerName,
&record.CustomerEmail,
&record.StartsAt,
&record.EndsAt,
&record.Status,
&record.Reference,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) {
var created CreatedBooking
err := r.pool.QueryRow(ctx, `
INSERT INTO bookings (
tenant_id, service_id, class_session_id, staff_id, location_id,
booking_mode, customer_name, customer_email, starts_at, ends_at,
status, reference, notes
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING id, reference, status
`,
params.TenantID,
params.ServiceID,
params.ClassSessionID,
params.StaffID,
params.LocationID,
params.BookingMode,
params.CustomerName,
params.CustomerEmail,
params.StartsAt,
params.EndsAt,
params.Status,
params.Reference,
params.Notes,
).Scan(&created.ID, &created.Reference, &created.Status)
return created, err
}
func (r *PGRepository) AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO waitlist_entries (tenant_id, class_session_id, customer_name, customer_email, position)
VALUES ($1,$2,$3,$4,$5)
`, params.TenantID, params.ClassSessionID, params.CustomerName, params.CustomerEmail, params.Position)
return err
}
func (r *PGRepository) CreateReminderJob(ctx context.Context, params ReminderJobParams) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO reminder_jobs (tenant_id, booking_id, channel, scheduled_for)
VALUES ($1, $2, $3, $4)
`, params.TenantID, params.BookingID, params.Channel, params.ScheduledFor)
return err
}
func (r *PGRepository) ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT rj.id, rj.tenant_id, t.name, t.locale, t.timezone,
rj.booking_id, rj.channel, rj.scheduled_for,
b.customer_name, b.customer_email, b.reference, b.starts_at, rj.status
FROM reminder_jobs rj
INNER JOIN bookings b ON b.id = rj.booking_id
INNER JOIN tenants t ON t.id = rj.tenant_id
WHERE rj.status = 'pending' AND rj.scheduled_for <= $1
ORDER BY rj.scheduled_for ASC
LIMIT $2
`, dueBefore, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ReminderJobRecord
for rows.Next() {
var record ReminderJobRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.TenantName,
&record.Locale,
&record.Timezone,
&record.BookingID,
&record.Channel,
&record.ScheduledFor,
&record.CustomerName,
&record.CustomerEmail,
&record.Reference,
&record.StartsAt,
&record.Status,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error {
_, err := r.pool.Exec(ctx, `
UPDATE reminder_jobs
SET status = $2, dispatched_at = $3
WHERE id = $1
`, reminderJobID, status, dispatchedAt)
return err
}
func (r *PGRepository) CreateNotificationDeliveryLog(ctx context.Context, params NotificationDeliveryLogParams) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO notification_delivery_logs (
tenant_id, reminder_job_id, channel, provider, recipient,
delivery_status, external_id, error_message
)
VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, ''))
`, params.TenantID, params.ReminderJobID, params.Channel, params.Provider, params.Recipient,
params.Status, params.ExternalID, params.ErrorMessage)
return err
}
func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string, startsAt time.Time, endsAt time.Time) (DashboardMetrics, error) {
var metrics DashboardMetrics
err := r.pool.QueryRow(ctx, `
SELECT
COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3) AS bookings_count,
COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'cancelled') AS cancellations_count,
COALESCE(
ROUND(
100.0 * COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'confirmed')
/ NULLIF(COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3), 0)
),
0
)::integer AS utilization_percent
FROM bookings
WHERE tenant_id = $1
`, tenantID, startsAt, endsAt).Scan(
&metrics.BookingsCount,
&metrics.CancellationsCount,
&metrics.UtilizationPercent,
)
return metrics, err
}
+152
View File
@@ -0,0 +1,152 @@
package db
import (
"context"
"time"
)
func (r *PGRepository) ListLocationsByTenant(ctx context.Context, tenantID string) ([]LocationRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, name, timezone, created_at
FROM locations
WHERE tenant_id = $1
ORDER BY name
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []LocationRecord
for rows.Next() {
var rec LocationRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) GetLocationByID(ctx context.Context, locationID string) (LocationRecord, error) {
var rec LocationRecord
err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, name, timezone, created_at
FROM locations
WHERE id = $1
`, locationID).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) CreateLocation(ctx context.Context, params CreateLocationParams) (LocationRecord, error) {
var rec LocationRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO locations (tenant_id, name, timezone)
VALUES ($1, $2, $3)
RETURNING id, tenant_id, name, timezone, created_at
`, params.TenantID, params.Name, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) UpdateLocation(ctx context.Context, locationID string, params UpdateLocationParams) (LocationRecord, error) {
var rec LocationRecord
err := r.pool.QueryRow(ctx, `
UPDATE locations
SET name = COALESCE($2, name),
timezone = COALESCE($3, timezone),
updated_at = now()
WHERE id = $1
RETURNING id, tenant_id, name, timezone, created_at
`, locationID, params.Name, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) DeleteLocation(ctx context.Context, locationID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM locations WHERE id = $1`, locationID)
return err
}
func (r *PGRepository) ListBlockedDaysByTenant(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BlockedDayRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, staff_id, starts_at, ends_at, kind, reason, created_at
FROM availability_exceptions
WHERE tenant_id = $1 AND starts_at <= $3 AND ends_at >= $2
ORDER BY starts_at
`, tenantID, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
var records []BlockedDayRecord
for rows.Next() {
var rec BlockedDayRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.StaffID, &rec.StartsAt, &rec.EndsAt, &rec.Kind, &rec.Reason, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) CreateBlockedDay(ctx context.Context, params CreateBlockedDayParams) (BlockedDayRecord, error) {
var rec BlockedDayRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO availability_exceptions (tenant_id, staff_id, starts_at, ends_at, kind, reason)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, tenant_id, staff_id, starts_at, ends_at, kind, reason, created_at
`, params.TenantID, params.StaffID, params.StartsAt, params.EndsAt, params.Kind, params.Reason).Scan(&rec.ID, &rec.TenantID, &rec.StaffID, &rec.StartsAt, &rec.EndsAt, &rec.Kind, &rec.Reason, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) UpdateBlockedDay(ctx context.Context, blockedDayID string, params UpdateBlockedDayParams) (BlockedDayRecord, error) {
var rec BlockedDayRecord
err := r.pool.QueryRow(ctx, `
UPDATE availability_exceptions
SET starts_at = COALESCE($2, starts_at),
ends_at = COALESCE($3, ends_at),
kind = COALESCE($4, kind),
reason = COALESCE($5, reason)
WHERE id = $1
RETURNING id, tenant_id, staff_id, starts_at, ends_at, kind, reason, created_at
`, blockedDayID, params.StartsAt, params.EndsAt, params.Kind, params.Reason).Scan(&rec.ID, &rec.TenantID, &rec.StaffID, &rec.StartsAt, &rec.EndsAt, &rec.Kind, &rec.Reason, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) DeleteBlockedDay(ctx context.Context, blockedDayID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM availability_exceptions WHERE id = $1`, blockedDayID)
return err
}
func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT tenant_id, staff_id, day_of_week, starts_local, ends_local
FROM availability_rules
WHERE tenant_id = $1 AND staff_id IS NULL
ORDER BY day_of_week
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []WorkingHoursRecord
for rows.Next() {
var rec WorkingHoursRecord
if err := rows.Scan(&rec.TenantID, &rec.StaffID, &rec.DayOfWeek, &rec.StartsLocal, &rec.EndsLocal); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error {
_, err := r.pool.Exec(ctx, `
UPDATE availability_rules
SET starts_local = COALESCE($3, starts_local),
ends_local = COALESCE($4, ends_local)
WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL
`, tenantID, dayOfWeek, params.StartsLocal, params.EndsLocal)
return err
}
-737
View File
@@ -371,751 +371,45 @@ func NewRepository(pools *Pools, demoMode bool) Repository {
return NewMemoryRepository()
}
func (r *PGRepository) GetTenantBySlug(ctx context.Context, slug string) (TenantRecord, error) {
var record TenantRecord
err := r.pool.QueryRow(ctx, `
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider, billing_customer_id, billing_subscription_id
FROM tenants
WHERE slug = $1
`, slug).Scan(
&record.ID,
&record.Slug,
&record.Name,
&record.Preset,
&record.Locale,
&record.Timezone,
&record.PlanCode,
&record.SubscriptionStatus,
&record.BillingProvider,
&record.BillingCustomerID,
&record.BillingSubscription,
)
if err != nil {
return TenantRecord{}, err
}
return record, nil
}
func (r *PGRepository) GetTenantByID(ctx context.Context, tenantID string) (TenantRecord, error) {
var record TenantRecord
err := r.pool.QueryRow(ctx, `
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider, billing_customer_id, billing_subscription_id
FROM tenants
WHERE id = $1
`, tenantID).Scan(
&record.ID,
&record.Slug,
&record.Name,
&record.Preset,
&record.Locale,
&record.Timezone,
&record.PlanCode,
&record.SubscriptionStatus,
&record.BillingProvider,
&record.BillingCustomerID,
&record.BillingSubscription,
)
if err != nil {
return TenantRecord{}, err
}
return record, nil
}
func (r *PGRepository) GetTenantByBillingCustomerID(ctx context.Context, customerID string) (TenantRecord, error) {
var record TenantRecord
err := r.pool.QueryRow(ctx, `
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider, billing_customer_id, billing_subscription_id
FROM tenants
WHERE billing_customer_id = $1
`, customerID).Scan(
&record.ID,
&record.Slug,
&record.Name,
&record.Preset,
&record.Locale,
&record.Timezone,
&record.PlanCode,
&record.SubscriptionStatus,
&record.BillingProvider,
&record.BillingCustomerID,
&record.BillingSubscription,
)
if err != nil {
return TenantRecord{}, err
}
return record, nil
}
func (r *PGRepository) EnsureUserIdentity(ctx context.Context, subject string, email string, displayName string) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO users (id, neon_subject, email, display_name)
VALUES (gen_random_uuid(), $1, COALESCE(NULLIF($2, ''), $1 || '@users.bookra.invalid'), NULLIF($3, ''))
ON CONFLICT (neon_subject) DO UPDATE SET
email = COALESCE(NULLIF(EXCLUDED.email, ''), users.email),
display_name = COALESCE(NULLIF(EXCLUDED.display_name, ''), users.display_name),
updated_at = now()
`, subject, email, displayName)
return err
}
func (r *PGRepository) CreateTenantForUser(ctx context.Context, params CreateTenantForUserParams) (TenantMembershipRecord, error) {
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return TenantMembershipRecord{}, err
}
defer tx.Rollback(ctx)
var userID string
if err := tx.QueryRow(ctx, `
SELECT id::text
FROM users
WHERE neon_subject = $1
`, params.Subject).Scan(&userID); err != nil {
return TenantMembershipRecord{}, err
}
record := TenantMembershipRecord{UserID: params.Subject, Role: "owner"}
if err := tx.QueryRow(ctx, `
INSERT INTO tenants (slug, name, preset, locale, timezone, plan_code, subscription_status)
VALUES ($1, $2, $3, $4, $5, 'starter', 'trialing')
RETURNING id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider, billing_customer_id, billing_subscription_id
`, params.Slug, params.Name, params.Preset, params.Locale, params.Timezone).Scan(
&record.Tenant.ID,
&record.Tenant.Slug,
&record.Tenant.Name,
&record.Tenant.Preset,
&record.Tenant.Locale,
&record.Tenant.Timezone,
&record.Tenant.PlanCode,
&record.Tenant.SubscriptionStatus,
&record.Tenant.BillingProvider,
&record.Tenant.BillingCustomerID,
&record.Tenant.BillingSubscription,
); err != nil {
return TenantMembershipRecord{}, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO tenant_users (tenant_id, user_id, role)
VALUES ($1, $2::uuid, 'owner')
`, record.Tenant.ID, userID); err != nil {
return TenantMembershipRecord{}, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO locations (tenant_id, name, timezone)
VALUES ($1, COALESCE(NULLIF($2, ''), 'Main location'), $3)
`, record.Tenant.ID, params.LocationName, params.Timezone); err != nil {
return TenantMembershipRecord{}, err
}
brandName := params.BrandName
if strings.TrimSpace(brandName) == "" {
brandName = params.Name
}
if _, err := tx.Exec(ctx, `
INSERT INTO brand_profiles (tenant_id, name, site_url, logo_url, primary_color)
VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, ''), NULLIF($5, ''))
ON CONFLICT (tenant_id) DO UPDATE SET
name = EXCLUDED.name,
site_url = EXCLUDED.site_url,
logo_url = EXCLUDED.logo_url,
primary_color = EXCLUDED.primary_color,
updated_at = now()
`, record.Tenant.ID, brandName, params.SiteURL, params.LogoURL, params.PrimaryColor); err != nil {
return TenantMembershipRecord{}, err
}
serviceName := params.ServiceName
if strings.TrimSpace(serviceName) == "" {
serviceName = "First appointment"
}
duration := params.DurationMinutes
if duration <= 0 {
duration = 60
}
if _, err := tx.Exec(ctx, `
INSERT INTO services (tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents)
VALUES ($1, $2, $3, $4, $5, 0)
`, record.Tenant.ID, serviceName, duration, maxInt(params.BufferBeforeMinutes, 0), maxInt(params.BufferAfterMinutes, 0)); err != nil {
return TenantMembershipRecord{}, err
}
cancelWindowHours := params.CancelWindowHours
if cancelWindowHours <= 0 {
cancelWindowHours = 24
}
if _, err := tx.Exec(ctx, `
INSERT INTO tenant_settings (tenant_id, cancel_window_hours, onboarding_completed)
VALUES ($1, $2, true)
ON CONFLICT (tenant_id) DO UPDATE SET
cancel_window_hours = EXCLUDED.cancel_window_hours,
onboarding_completed = true,
updated_at = now()
`, record.Tenant.ID, cancelWindowHours); err != nil {
return TenantMembershipRecord{}, err
}
blocks := params.AvailabilityBlocks
if len(blocks) == 0 {
blocks = defaultAvailabilityBlocks()
}
for _, block := range blocks {
if block.Busy {
continue
}
if _, err := tx.Exec(ctx, `
INSERT INTO availability_rules (tenant_id, day_of_week, starts_local, ends_local)
VALUES ($1, $2, $3::time, $4::time)
`, record.Tenant.ID, block.DayOfWeek, block.StartsLocal, block.EndsLocal); err != nil {
return TenantMembershipRecord{}, err
}
}
for _, invite := range params.TeamInvites {
email := strings.TrimSpace(strings.ToLower(invite.Email))
if email == "" {
continue
}
role := strings.TrimSpace(invite.Role)
if role == "" {
role = "staff"
}
if _, err := tx.Exec(ctx, `
INSERT INTO team_invites (tenant_id, email, role, status)
VALUES ($1, $2, $3, 'pending')
ON CONFLICT (tenant_id, email) DO UPDATE SET
role = EXCLUDED.role,
status = 'pending',
updated_at = now()
`, record.Tenant.ID, email, role); err != nil {
return TenantMembershipRecord{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return TenantMembershipRecord{}, err
}
return record, nil
}
func (r *PGRepository) GetBrandProfile(ctx context.Context, tenantID string) (BrandProfileRecord, error) {
var record BrandProfileRecord
err := r.pool.QueryRow(ctx, `
SELECT tenant_id, name, COALESCE(site_url, ''), COALESCE(logo_url, ''), COALESCE(primary_color, ''), COALESCE(umami_site_id, '')
FROM brand_profiles
WHERE tenant_id = $1
`, tenantID).Scan(
&record.TenantID,
&record.Name,
&record.SiteURL,
&record.LogoURL,
&record.PrimaryColor,
&record.UmamiSiteID,
)
return record, err
}
func (r *PGRepository) GetTenantMembershipByUserID(ctx context.Context, userID string) (TenantMembershipRecord, error) {
var record TenantMembershipRecord
err := r.pool.QueryRow(ctx, `
SELECT t.id, t.slug, t.name, t.preset, t.locale, t.timezone, t.plan_code, t.subscription_status,
t.billing_provider, t.billing_customer_id, t.billing_subscription_id, u.neon_subject, tu.role
FROM tenant_users tu
INNER JOIN users u ON u.id = tu.user_id
INNER JOIN tenants t ON t.id = tu.tenant_id
WHERE u.neon_subject = $1 OR tu.user_id::text = $1
ORDER BY tu.created_at ASC
LIMIT 1
`, userID).Scan(
&record.Tenant.ID,
&record.Tenant.Slug,
&record.Tenant.Name,
&record.Tenant.Preset,
&record.Tenant.Locale,
&record.Tenant.Timezone,
&record.Tenant.PlanCode,
&record.Tenant.SubscriptionStatus,
&record.Tenant.BillingProvider,
&record.Tenant.BillingCustomerID,
&record.Tenant.BillingSubscription,
&record.UserID,
&record.Role,
)
if err != nil {
return TenantMembershipRecord{}, err
}
return record, nil
}
func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents
FROM services
WHERE tenant_id = $1
ORDER BY created_at ASC
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ServiceRecord
for rows.Next() {
var record ServiceRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.Name,
&record.DurationMinutes,
&record.BufferBeforeMinutes,
&record.BufferAfterMinutes,
&record.PriceCents,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, staff_id, day_of_week, starts_local, ends_local
FROM availability_rules
WHERE tenant_id = $1
ORDER BY day_of_week ASC, starts_local ASC
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []AvailabilityRuleRecord
for rows.Next() {
var record AvailabilityRuleRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.StaffID,
&record.DayOfWeek,
&record.StartsLocal,
&record.EndsLocal,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) ListClassSessionsByTenant(ctx context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT cs.id, cs.tenant_id, cs.template_id, cs.location_id, ct.title, cs.starts_at, cs.ends_at, cs.capacity
FROM class_sessions cs
INNER JOIN class_templates ct ON ct.id = cs.template_id
WHERE cs.tenant_id = $1 AND cs.starts_at >= $2
ORDER BY cs.starts_at ASC
LIMIT $3
`, tenantID, from, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ClassSessionRecord
for rows.Next() {
var record ClassSessionRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.TemplateID,
&record.LocationID,
&record.Title,
&record.StartsAt,
&record.EndsAt,
&record.Capacity,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
customer_name, customer_email, starts_at, ends_at, status, reference
FROM bookings
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
ORDER BY starts_at ASC
`, tenantID, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
var records []BookingRecord
for rows.Next() {
var record BookingRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.ServiceID,
&record.ClassSessionID,
&record.StaffID,
&record.LocationID,
&record.CustomerName,
&record.CustomerEmail,
&record.StartsAt,
&record.EndsAt,
&record.Status,
&record.Reference,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) {
var created CreatedBooking
err := r.pool.QueryRow(ctx, `
INSERT INTO bookings (
tenant_id, service_id, class_session_id, staff_id, location_id,
booking_mode, customer_name, customer_email, starts_at, ends_at,
status, reference, notes
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING id, reference, status
`,
params.TenantID,
params.ServiceID,
params.ClassSessionID,
params.StaffID,
params.LocationID,
params.BookingMode,
params.CustomerName,
params.CustomerEmail,
params.StartsAt,
params.EndsAt,
params.Status,
params.Reference,
params.Notes,
).Scan(&created.ID, &created.Reference, &created.Status)
return created, err
}
func (r *PGRepository) AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO waitlist_entries (tenant_id, class_session_id, customer_name, customer_email, position)
VALUES ($1,$2,$3,$4,$5)
`, params.TenantID, params.ClassSessionID, params.CustomerName, params.CustomerEmail, params.Position)
return err
}
func (r *PGRepository) CreateReminderJob(ctx context.Context, params ReminderJobParams) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO reminder_jobs (tenant_id, booking_id, channel, scheduled_for)
VALUES ($1, $2, $3, $4)
`, params.TenantID, params.BookingID, params.Channel, params.ScheduledFor)
return err
}
func (r *PGRepository) ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT rj.id, rj.tenant_id, t.name, t.locale, t.timezone,
rj.booking_id, rj.channel, rj.scheduled_for,
b.customer_name, b.customer_email, b.reference, b.starts_at, rj.status
FROM reminder_jobs rj
INNER JOIN bookings b ON b.id = rj.booking_id
INNER JOIN tenants t ON t.id = rj.tenant_id
WHERE rj.status = 'pending' AND rj.scheduled_for <= $1
ORDER BY rj.scheduled_for ASC
LIMIT $2
`, dueBefore, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ReminderJobRecord
for rows.Next() {
var record ReminderJobRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.TenantName,
&record.Locale,
&record.Timezone,
&record.BookingID,
&record.Channel,
&record.ScheduledFor,
&record.CustomerName,
&record.CustomerEmail,
&record.Reference,
&record.StartsAt,
&record.Status,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error {
_, err := r.pool.Exec(ctx, `
UPDATE reminder_jobs
SET status = $2, dispatched_at = $3
WHERE id = $1
`, reminderJobID, status, dispatchedAt)
return err
}
func (r *PGRepository) CreateNotificationDeliveryLog(ctx context.Context, params NotificationDeliveryLogParams) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO notification_delivery_logs (
tenant_id, reminder_job_id, channel, provider, recipient,
delivery_status, external_id, error_message
)
VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, ''))
`, params.TenantID, params.ReminderJobID, params.Channel, params.Provider, params.Recipient,
params.Status, params.ExternalID, params.ErrorMessage)
return err
}
func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string, startsAt time.Time, endsAt time.Time) (DashboardMetrics, error) {
var metrics DashboardMetrics
err := r.pool.QueryRow(ctx, `
SELECT
COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3) AS bookings_count,
COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'cancelled') AS cancellations_count,
COALESCE(
ROUND(
100.0 * COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'confirmed')
/ NULLIF(COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3), 0)
),
0
)::integer AS utilization_percent
FROM bookings
WHERE tenant_id = $1
`, tenantID, startsAt, endsAt).Scan(
&metrics.BookingsCount,
&metrics.CancellationsCount,
&metrics.UtilizationPercent,
)
return metrics, err
}
func (r *PGRepository) GetSubscriptionSnapshot(ctx context.Context, tenantID string) (BillingSnapshotRecord, error) {
var record BillingSnapshotRecord
err := r.pool.QueryRow(ctx, `
SELECT tenant_id, billing_provider, billing_customer_id, billing_subscription_id, status, plan_code, COALESCE(currency, 'czk'), price_id,
cancel_at_period_end, current_period_start, current_period_end,
payment_method_brand, payment_method_last4, last_synced_at
FROM billing_snapshots
WHERE tenant_id = $1
`, tenantID).Scan(
&record.TenantID,
&record.BillingProvider,
&record.BillingCustomerID,
&record.BillingSubscriptionID,
&record.Status,
&record.PlanCode,
&record.Currency,
&record.PriceID,
&record.CancelAtPeriodEnd,
&record.CurrentPeriodStart,
&record.CurrentPeriodEnd,
&record.PaymentMethodBrand,
&record.PaymentMethodLast4,
&record.LastSyncedAt,
)
return record, err
}
func (r *PGRepository) UpsertSubscriptionSnapshot(ctx context.Context, params BillingSnapshotRecord) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO billing_snapshots (
tenant_id, billing_provider, billing_customer_id, billing_subscription_id, status, plan_code, currency, price_id,
cancel_at_period_end, current_period_start, current_period_end,
payment_method_brand, payment_method_last4, last_synced_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
ON CONFLICT (tenant_id) DO UPDATE SET
billing_provider = EXCLUDED.billing_provider,
billing_customer_id = EXCLUDED.billing_customer_id,
billing_subscription_id = EXCLUDED.billing_subscription_id,
status = EXCLUDED.status,
plan_code = EXCLUDED.plan_code,
currency = EXCLUDED.currency,
price_id = EXCLUDED.price_id,
cancel_at_period_end = EXCLUDED.cancel_at_period_end,
current_period_start = EXCLUDED.current_period_start,
current_period_end = EXCLUDED.current_period_end,
payment_method_brand = EXCLUDED.payment_method_brand,
payment_method_last4 = EXCLUDED.payment_method_last4,
last_synced_at = EXCLUDED.last_synced_at,
updated_at = now()
`, params.TenantID, firstNonEmpty(params.BillingProvider, "paddle"), params.BillingCustomerID, params.BillingSubscriptionID, params.Status, params.PlanCode,
firstNonEmpty(params.Currency, "czk"), params.PriceID, params.CancelAtPeriodEnd, params.CurrentPeriodStart, params.CurrentPeriodEnd,
params.PaymentMethodBrand, params.PaymentMethodLast4, params.LastSyncedAt)
return err
}
func (r *PGRepository) UpdateTenantBillingCustomerID(ctx context.Context, tenantID string, customerID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE tenants
SET billing_provider = 'paddle', billing_customer_id = $2, updated_at = now()
WHERE id = $1
`, tenantID, customerID)
return err
}
func (r *PGRepository) UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE tenants
SET billing_provider = 'paddle', plan_code = $2, subscription_status = $3, billing_subscription_id = $4, updated_at = now()
WHERE id = $1
`, tenantID, planCode, subscriptionStatus, subscriptionID)
return err
}
func (r *PGRepository) RecordBillingEvent(ctx context.Context, tenantID string, provider string, eventID string, eventType string, payload []byte) (bool, error) {
result, err := r.pool.Exec(ctx, `
INSERT INTO subscription_events (tenant_id, billing_provider, billing_provider_event_id, event_type, payload, processed_at)
VALUES ($1, $2, $3, $4, $5::jsonb, now())
ON CONFLICT (billing_provider, billing_provider_event_id) DO NOTHING
`, tenantID, firstNonEmpty(provider, "paddle"), eventID, eventType, payload)
if err != nil {
return false, err
}
return result.RowsAffected() == 1, nil
}
// ============================================
// LOCATION / ZONE METHODS - PG REPOSITORY (STUBS)
// ============================================
func (r *PGRepository) ListLocationsByTenant(ctx context.Context, tenantID string) ([]LocationRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, name, timezone, created_at
FROM locations
WHERE tenant_id = $1
ORDER BY name
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []LocationRecord
for rows.Next() {
var rec LocationRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) GetLocationByID(ctx context.Context, locationID string) (LocationRecord, error) {
var rec LocationRecord
err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, name, timezone, created_at
FROM locations
WHERE id = $1
`, locationID).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) CreateLocation(ctx context.Context, params CreateLocationParams) (LocationRecord, error) {
var rec LocationRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO locations (tenant_id, name, timezone)
VALUES ($1, $2, $3)
RETURNING id, tenant_id, name, timezone, created_at
`, params.TenantID, params.Name, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) UpdateLocation(ctx context.Context, locationID string, params UpdateLocationParams) (LocationRecord, error) {
var rec LocationRecord
err := r.pool.QueryRow(ctx, `
UPDATE locations
SET name = COALESCE($2, name),
timezone = COALESCE($3, timezone),
updated_at = now()
WHERE id = $1
RETURNING id, tenant_id, name, timezone, created_at
`, locationID, params.Name, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) DeleteLocation(ctx context.Context, locationID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM locations WHERE id = $1`, locationID)
return err
}
// ============================================
// BLOCKED DAYS METHODS - PG REPOSITORY (STUBS)
// ============================================
func (r *PGRepository) ListBlockedDaysByTenant(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BlockedDayRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, staff_id, starts_at, ends_at, kind, reason, created_at
FROM availability_exceptions
WHERE tenant_id = $1 AND starts_at <= $3 AND ends_at >= $2
ORDER BY starts_at
`, tenantID, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
var records []BlockedDayRecord
for rows.Next() {
var rec BlockedDayRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.StaffID, &rec.StartsAt, &rec.EndsAt, &rec.Kind, &rec.Reason, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) CreateBlockedDay(ctx context.Context, params CreateBlockedDayParams) (BlockedDayRecord, error) {
var rec BlockedDayRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO availability_exceptions (tenant_id, staff_id, starts_at, ends_at, kind, reason)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, tenant_id, staff_id, starts_at, ends_at, kind, reason, created_at
`, params.TenantID, params.StaffID, params.StartsAt, params.EndsAt, params.Kind, params.Reason).Scan(&rec.ID, &rec.TenantID, &rec.StaffID, &rec.StartsAt, &rec.EndsAt, &rec.Kind, &rec.Reason, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) UpdateBlockedDay(ctx context.Context, blockedDayID string, params UpdateBlockedDayParams) (BlockedDayRecord, error) {
var rec BlockedDayRecord
err := r.pool.QueryRow(ctx, `
UPDATE availability_exceptions
SET starts_at = COALESCE($2, starts_at),
ends_at = COALESCE($3, ends_at),
kind = COALESCE($4, kind),
reason = COALESCE($5, reason)
WHERE id = $1
RETURNING id, tenant_id, staff_id, starts_at, ends_at, kind, reason, created_at
`, blockedDayID, params.StartsAt, params.EndsAt, params.Kind, params.Reason).Scan(&rec.ID, &rec.TenantID, &rec.StaffID, &rec.StartsAt, &rec.EndsAt, &rec.Kind, &rec.Reason, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) DeleteBlockedDay(ctx context.Context, blockedDayID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM availability_exceptions WHERE id = $1`, blockedDayID)
return err
}
// ============================================
// CUSTOMER METHODS - PG REPOSITORY (STUBS)
@@ -1247,38 +541,7 @@ func (r *PGRepository) RescheduleBooking(ctx context.Context, bookingID string,
// WORKING HOURS METHODS - PG REPOSITORY (STUBS)
// ============================================
func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT tenant_id, staff_id, day_of_week, starts_local, ends_local
FROM availability_rules
WHERE tenant_id = $1 AND staff_id IS NULL
ORDER BY day_of_week
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []WorkingHoursRecord
for rows.Next() {
var rec WorkingHoursRecord
if err := rows.Scan(&rec.TenantID, &rec.StaffID, &rec.DayOfWeek, &rec.StartsLocal, &rec.EndsLocal); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error {
_, err := r.pool.Exec(ctx, `
UPDATE availability_rules
SET starts_local = COALESCE($3, starts_local),
ends_local = COALESCE($4, ends_local)
WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL
`, tenantID, dayOfWeek, params.StartsLocal, params.EndsLocal)
return err
}
type MemoryRepository struct {
tenant TenantRecord
+101
View File
@@ -0,0 +1,101 @@
package db
import (
"context"
"time"
)
func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents
FROM services
WHERE tenant_id = $1
ORDER BY created_at ASC
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ServiceRecord
for rows.Next() {
var record ServiceRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.Name,
&record.DurationMinutes,
&record.BufferBeforeMinutes,
&record.BufferAfterMinutes,
&record.PriceCents,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, staff_id, day_of_week, starts_local, ends_local
FROM availability_rules
WHERE tenant_id = $1
ORDER BY day_of_week ASC, starts_local ASC
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []AvailabilityRuleRecord
for rows.Next() {
var record AvailabilityRuleRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.StaffID,
&record.DayOfWeek,
&record.StartsLocal,
&record.EndsLocal,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) ListClassSessionsByTenant(ctx context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT cs.id, cs.tenant_id, cs.template_id, cs.location_id, ct.title, cs.starts_at, cs.ends_at, cs.capacity
FROM class_sessions cs
INNER JOIN class_templates ct ON ct.id = cs.template_id
WHERE cs.tenant_id = $1 AND cs.starts_at >= $2
ORDER BY cs.starts_at ASC
LIMIT $3
`, tenantID, from, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ClassSessionRecord
for rows.Next() {
var record ClassSessionRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.TemplateID,
&record.LocationID,
&record.Title,
&record.StartsAt,
&record.EndsAt,
&record.Capacity,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
+210
View File
@@ -0,0 +1,210 @@
package db
import (
"context"
"github.com/jackc/pgx/v5"
)
func (r *PGRepository) GetTenantBySlug(ctx context.Context, slug string) (TenantRecord, error) {
var record TenantRecord
err := r.pool.QueryRow(ctx, `
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider, billing_customer_id, billing_subscription_id
FROM tenants
WHERE slug = $1
`, slug).Scan(
&record.ID, &record.Slug, &record.Name, &record.Preset, &record.Locale, &record.Timezone,
&record.PlanCode, &record.SubscriptionStatus, &record.BillingProvider,
&record.BillingCustomerID, &record.BillingSubscription,
)
return record, err
}
func (r *PGRepository) GetTenantByID(ctx context.Context, tenantID string) (TenantRecord, error) {
var record TenantRecord
err := r.pool.QueryRow(ctx, `
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider, billing_customer_id, billing_subscription_id
FROM tenants
WHERE id = $1
`, tenantID).Scan(
&record.ID, &record.Slug, &record.Name, &record.Preset, &record.Locale, &record.Timezone,
&record.PlanCode, &record.SubscriptionStatus, &record.BillingProvider,
&record.BillingCustomerID, &record.BillingSubscription,
)
return record, err
}
func (r *PGRepository) GetTenantByBillingCustomerID(ctx context.Context, customerID string) (TenantRecord, error) {
var record TenantRecord
err := r.pool.QueryRow(ctx, `
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider, billing_customer_id, billing_subscription_id
FROM tenants
WHERE billing_customer_id = $1
`, customerID).Scan(
&record.ID, &record.Slug, &record.Name, &record.Preset, &record.Locale, &record.Timezone,
&record.PlanCode, &record.SubscriptionStatus, &record.BillingProvider,
&record.BillingCustomerID, &record.BillingSubscription,
)
return record, err
}
func (r *PGRepository) EnsureUserIdentity(ctx context.Context, subject string, email string, displayName string) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO users (id, neon_subject, email, display_name)
VALUES (gen_random_uuid(), $1, COALESCE(NULLIF($2, ''), $1 || '@users.bookra.invalid'), NULLIF($3, ''))
ON CONFLICT (neon_subject) DO UPDATE SET email = EXCLUDED.email, display_name = COALESCE(NULLIF($3, ''), users.display_name)
`, subject, email, displayName)
return err
}
func (r *PGRepository) CreateTenantForUser(ctx context.Context, params CreateTenantForUserParams) (TenantMembershipRecord, error) {
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return TenantMembershipRecord{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
var tenantID string
err = tx.QueryRow(ctx, `
INSERT INTO tenants (id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider)
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, 'starter', 'inactive', '')
RETURNING id
`, params.Slug, params.Name, params.Preset, params.Locale, params.Timezone).Scan(&tenantID)
if err != nil {
return TenantMembershipRecord{}, err
}
_, err = tx.Exec(ctx, `
INSERT INTO brand_profiles (tenant_id, name, site_url, logo_url, primary_color)
VALUES ($1, $2, NULLIF($3,''), NULLIF($4,''), NULLIF($5,''))
`, tenantID, params.BrandName, params.SiteURL, params.LogoURL, params.PrimaryColor)
if err != nil {
return TenantMembershipRecord{}, err
}
locationID := ""
if params.LocationName != "" {
err = tx.QueryRow(ctx, `
INSERT INTO locations (id, tenant_id, name, timezone)
VALUES (gen_random_uuid(), $1, $2, $3)
RETURNING id
`, tenantID, params.LocationName, params.Timezone).Scan(&locationID)
if err != nil {
return TenantMembershipRecord{}, err
}
}
if params.ServiceName != "" && params.DurationMinutes > 0 {
_, err = tx.Exec(ctx, `
INSERT INTO services (id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents)
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, 0)
`, tenantID, params.ServiceName, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes)
if err != nil {
return TenantMembershipRecord{}, err
}
}
for _, block := range params.AvailabilityBlocks {
_, err = tx.Exec(ctx, `
INSERT INTO availability_rules (id, tenant_id, day_of_week, starts_local, ends_local)
VALUES (gen_random_uuid(), $1, $2, $3, $4)
`, tenantID, block.DayOfWeek, block.StartsLocal, block.EndsLocal)
if err != nil {
return TenantMembershipRecord{}, err
}
}
for _, invite := range params.TeamInvites {
_, _ = tx.Exec(ctx, `
INSERT INTO team_invites (tenant_id, email, role, expires_at)
VALUES ($1, $2, $3, now() + interval '7 days')
`, tenantID, invite.Email, invite.Role)
}
_, err = tx.Exec(ctx, `
INSERT INTO users (id, neon_subject, email, display_name)
VALUES (gen_random_uuid(), $1, $2, $3)
ON CONFLICT (neon_subject) DO NOTHING
`, params.Subject, params.Subject+"@users.bookra.invalid", "")
if err != nil {
return TenantMembershipRecord{}, err
}
var userID string
err = tx.QueryRow(ctx, `
INSERT INTO tenant_memberships (id, tenant_id, user_neon_subject, role, joined_at)
SELECT gen_random_uuid(), $1, u.neon_subject, 'owner', now()
FROM users u WHERE u.neon_subject = $2
ON CONFLICT (tenant_id, user_neon_subject) DO UPDATE SET role = 'owner'
RETURNING id
`, tenantID, params.Subject).Scan(&userID)
if err != nil {
return TenantMembershipRecord{}, err
}
err = tx.Commit(ctx)
if err != nil {
return TenantMembershipRecord{}, err
}
return TenantMembershipRecord{
Tenant: TenantRecord{
ID: tenantID,
Slug: params.Slug,
Name: params.Name,
Preset: params.Preset,
Locale: params.Locale,
Timezone: params.Timezone,
},
UserID: userID,
Role: "owner",
}, nil
}
func (r *PGRepository) GetBrandProfile(ctx context.Context, tenantID string) (BrandProfileRecord, error) {
var record BrandProfileRecord
err := r.pool.QueryRow(ctx, `
SELECT tenant_id, name, COALESCE(site_url, ''), COALESCE(logo_url, ''), COALESCE(primary_color, ''), COALESCE(umami_site_id, '')
FROM brand_profiles WHERE tenant_id = $1
`, tenantID).Scan(&record.TenantID, &record.Name, &record.SiteURL, &record.LogoURL, &record.PrimaryColor, &record.UmamiSiteID)
return record, err
}
func (r *PGRepository) GetTenantMembershipByUserID(ctx context.Context, userID string) (TenantMembershipRecord, error) {
var record TenantMembershipRecord
err := r.pool.QueryRow(ctx, `
SELECT t.id, t.slug, t.name, t.preset, t.locale, t.timezone, t.plan_code, t.subscription_status,
t.billing_provider, t.billing_customer_id, t.billing_subscription_id,
tm.user_neon_subject, tm.role
FROM tenant_memberships tm
JOIN tenants t ON t.id = tm.tenant_id
JOIN users u ON u.neon_subject = tm.user_neon_subject
WHERE u.id = $1
ORDER BY tm.joined_at DESC
LIMIT 1
`, userID).Scan(
&record.Tenant.ID, &record.Tenant.Slug, &record.Tenant.Name, &record.Tenant.Preset,
&record.Tenant.Locale, &record.Tenant.Timezone, &record.Tenant.PlanCode, &record.Tenant.SubscriptionStatus,
&record.Tenant.BillingProvider, &record.Tenant.BillingCustomerID, &record.Tenant.BillingSubscription,
&record.UserID, &record.Role,
)
return record, err
}
func (r *PGRepository) UpdateTenantBillingCustomerID(ctx context.Context, tenantID string, customerID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE tenants
SET billing_provider = 'paddle', billing_customer_id = $2, updated_at = now()
WHERE id = $1
`, tenantID, customerID)
return err
}
func (r *PGRepository) UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE tenants
SET billing_provider = 'paddle', plan_code = $2, subscription_status = $3, billing_subscription_id = $4, updated_at = now()
WHERE id = $1
`, tenantID, planCode, subscriptionStatus, subscriptionID)
return err
}
+17
View File
@@ -0,0 +1,17 @@
package shared
import "strings"
// NormalizePlanCode canonicalizes plan codes from various sources
// (Paddle checkout, webhook payloads, database records) into stable
// internal identifiers used across the billing and tenancy domains.
func NormalizePlanCode(planCode string) string {
switch strings.TrimSpace(planCode) {
case "growth":
return "pro"
case "multi-location":
return "business"
default:
return strings.TrimSpace(planCode)
}
}
+3 -13
View File
@@ -9,6 +9,7 @@ import (
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/shared"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
@@ -58,7 +59,7 @@ func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (do
Preset: membership.Tenant.Preset,
Locale: membership.Tenant.Locale,
Timezone: membership.Tenant.Timezone,
PlanCode: normalizePlanCode(membership.Tenant.PlanCode),
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
OnboardingCompleted: true,
Brand: s.brandProfile(ctx, membership.Tenant),
CurrentUser: domain.Principal{
@@ -162,7 +163,7 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
Preset: membership.Tenant.Preset,
Locale: membership.Tenant.Locale,
Timezone: membership.Tenant.Timezone,
PlanCode: normalizePlanCode(membership.Tenant.PlanCode),
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
OnboardingCompleted: true,
Brand: domain.BrandProfile{
Name: strings.TrimSpace(brand.Name),
@@ -252,17 +253,6 @@ func normalizeClock(value string) string {
return value
}
func normalizePlanCode(planCode string) string {
switch planCode {
case "growth":
return "pro"
case "multi-location":
return "business"
default:
return planCode
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
import type { ParentComponent } from "solid-js";
import { createEffect, onMount } from "solid-js";
import { createEffect } from "solid-js";
import { useLocation } from "@solidjs/router";
import { AuthProvider } from "./providers/auth-provider";
import { I18nProvider } from "./providers/i18n-provider";
@@ -1,4 +1,4 @@
import { JSX, createSignal, onMount, Show } from "solid-js";
import { JSX, createSignal, Show } from "solid-js";
export type CharacterPose =
| "main"
@@ -1,4 +1,4 @@
import { createSignal, Show, For, type JSX } from "solid-js";
import { createSignal, For } from "solid-js";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Button, Tabs, TabsList, TabsTrigger, TabsContent, DialogCloseButton } from "./ui";
interface IntegrationModalProps {
+9 -2
View File
@@ -1,5 +1,12 @@
import { JSX, For, createSignal, createContext, useContext, ParentComponent, splitProps, children, Accessor } from "solid-js";
import type { ResolvedChildren } from "solid-js";
import {
JSX,
createSignal,
createContext,
useContext,
ParentComponent,
splitProps,
Accessor
} from "solid-js";
// Context for tab state
interface TabsContextValue {
+11 -9
View File
@@ -1,17 +1,19 @@
import { render } from "solid-js/web";
import { lazy } from "solid-js";
import { Route, Router } from "@solidjs/router";
import App from "./App";
import { AboutRoute } from "./routes/about-route";
import { AuthCallbackRoute } from "./routes/auth-callback-route";
import { BookingManageRoute } from "./routes/booking-manage-route";
import { ContactRoute } from "./routes/contact-route";
import { DashboardRoute } from "./routes/dashboard-route";
import { HomeRoute } from "./routes/home-route";
import { LegalRoute } from "./routes/legal-route";
import { NotFoundRoute } from "./routes/not-found-route";
import { PublicBookingRoute } from "./routes/public-booking-route";
import "./styles/index.css";
const HomeRoute = lazy(() => import("./routes/home-route").then((module) => ({ default: module.HomeRoute })));
const AboutRoute = lazy(() => import("./routes/about-route").then((module) => ({ default: module.AboutRoute })));
const AuthCallbackRoute = lazy(() => import("./routes/auth-callback-route").then((module) => ({ default: module.AuthCallbackRoute })));
const ContactRoute = lazy(() => import("./routes/contact-route").then((module) => ({ default: module.ContactRoute })));
const DashboardRoute = lazy(() => import("./routes/dashboard-route").then((module) => ({ default: module.DashboardRoute })));
const PublicBookingRoute = lazy(() => import("./routes/public-booking-route").then((module) => ({ default: module.PublicBookingRoute })));
const BookingManageRoute = lazy(() => import("./routes/booking-manage-route").then((module) => ({ default: module.BookingManageRoute })));
const LegalRoute = lazy(() => import("./routes/legal-route").then((module) => ({ default: module.LegalRoute })));
const NotFoundRoute = lazy(() => import("./routes/not-found-route").then((module) => ({ default: module.NotFoundRoute })));
render(
() => (
<Router root={App}>
@@ -1,5 +1,5 @@
import { A, useParams, useSearchParams } from "@solidjs/router";
import { createSignal, createResource, Show, Match, Switch } from "solid-js";
import { useParams, useSearchParams } from "@solidjs/router";
import { createSignal, createResource, Show } from "solid-js";
import { apiClient } from "../lib/api-client";
import { useI18n } from "../providers/i18n-provider";
import { BookraCharacter } from "../components/bookra-character";
+8 -1
View File
@@ -1,7 +1,14 @@
import { Show, createSignal } from "solid-js";
import { useI18n } from "../providers/i18n-provider";
import { BookraCharacter } from "../components/bookra-character";
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Textarea } from "../components/ui";
import {
Button,
Card,
CardContent,
CardTitle,
Input,
Textarea
} from "../components/ui";
export function ContactRoute() {
const i18n = useI18n();
+13 -2
View File
@@ -1,4 +1,10 @@
import { For, Show, createResource, createSignal, createMemo, createEffect, onMount } from "solid-js";
import {
Show,
createResource,
createSignal,
createMemo,
createEffect
} from "solid-js";
import { A, useSearchParams } from "@solidjs/router";
import { apiClient } from "../lib/api-client";
import { getPaddle, paddleConfigured } from "../lib/paddle";
@@ -731,7 +737,12 @@ export function DashboardRoute() {
setBillingError(i18n.locale() === "cs" ? "Paddle portál není připraven." : "Paddle portal is not ready.");
return;
}
window.location.href = response.data.url;
const portalUrl = response.data.url;
if (!portalUrl.startsWith("https://")) {
setBillingError(i18n.locale() === "cs" ? "Neplatný portál URL." : "Invalid portal URL.");
return;
}
window.location.href = portalUrl;
};
createEffect(() => {
+3 -6
View File
@@ -1,5 +1,5 @@
import { A } from "@solidjs/router";
import { createSignal, onMount, JSX, createMemo } from "solid-js";
import { createSignal, onMount, createMemo } from "solid-js";
import { useI18n } from "../providers/i18n-provider";
import { BookraCharacter } from "../components/bookra-character";
@@ -637,11 +637,8 @@ export function HomeRoute() {
</section>
{/* CTA Section */}
<section class="py-20 lg:py-32 relative overflow-hidden">
<div class="absolute inset-0 pointer-events-none">
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-accent/5 rounded-full blur-3xl" />
</div>
<div class="section-container relative">
<section class="py-20 lg:py-32">
<div class="section-container">
<div class="max-w-3xl mx-auto text-center">
{/* Celebratory mascot */}
<div class="flex justify-center mb-6">