package report import ( "context" "encoding/json" "errors" "time" "github.com/google/uuid" "github.com/yuxingu/digital-psychology/apps/api/internal/model" "github.com/yuxingu/digital-psychology/apps/api/internal/portrait" "github.com/yuxingu/digital-psychology/apps/api/internal/repository" "github.com/yuxingu/digital-psychology/apps/api/internal/rhythm" "github.com/yuxingu/digital-psychology/apps/api/internal/star" "github.com/yuxingu/digital-psychology/apps/api/internal/star/synastry" "github.com/yuxingu/digital-psychology/apps/api/internal/textsafe" ) // Service creates and reads growth reports with entitlement trimming. type Service struct { Profiles *repository.ProfileRepo Reports *repository.ReportRepo Invites *repository.SynastryInviteRepo } // Nearby lists geo-visible self profiles of other users within radius. func (s *Service) Nearby(ctx context.Context, userID uuid.UUID, lat, lng, radiusKm float64) ([]repository.NearbyItem, error) { if s.Profiles == nil { return nil, errors.New("profiles unavailable") } if radiusKm <= 0 { radiusKm = 50 } if radiusKm > 200 { radiusKm = 200 } return s.Profiles.ListNearby(ctx, userID, lat, lng, radiusKm, 20) } // CreateInvite creates a shareable synastry invite for host profile. func (s *Service) CreateInvite(ctx context.Context, userID, hostProfileID uuid.UUID) (*model.SynastryInvite, error) { if s.Invites == nil { return nil, errors.New("invites unavailable") } if _, err := s.Profiles.GetForUser(ctx, userID, hostProfileID); err != nil { return nil, errors.New("profile not found") } return s.Invites.Create(ctx, userID, hostProfileID) } // GetInvite returns invite by token (public metadata for landing page). func (s *Service) GetInvite(ctx context.Context, token string) (map[string]any, error) { if s.Invites == nil { return nil, errors.New("invites unavailable") } inv, err := s.Invites.GetByToken(ctx, token) if err != nil { return nil, errors.New("invite not found") } if time.Now().After(inv.ExpiresAt) { return nil, errors.New("invite expired") } host, err := s.Profiles.GetByID(ctx, inv.HostProfileID) if err != nil { return nil, errors.New("host profile missing") } return map[string]any{ "token": inv.Token, "expires_at": inv.ExpiresAt, "host_name": host.DisplayName, "already_accepted": inv.ReportID != nil, }, nil } // AcceptInvite creates guest other-profile for guest user, builds synastry, marks invite (atomic). func (s *Service) AcceptInvite(ctx context.Context, guestUser uuid.UUID, token, displayName, birthDate string, birthTime, birthPlace *string) (*model.GrowthReport, error) { if s.Invites == nil { return nil, errors.New("invites unavailable") } inv, err := s.Invites.GetByToken(ctx, token) if err != nil { return nil, errors.New("invite not found") } if time.Now().After(inv.ExpiresAt) { return nil, errors.New("invite expired") } if inv.ReportID != nil { return nil, errors.New("invite already used") } if inv.HostUserID == guestUser { return nil, errors.New("不能接受自己的邀请") } birth, err := time.Parse("2006-01-02", birthDate) if err != nil { return nil, errors.New("birth_date must be YYYY-MM-DD") } name := displayName if name == "" { name = "TA" } name, err = textsafe.Check(textsafe.DisplayName, name) if err != nil { return nil, err } host, err := s.Profiles.GetByID(ctx, inv.HostProfileID) if err != nil { return nil, errors.New("host profile missing") } ca, err := star.NatalChart(host.BirthDate, host.BirthTime, host.BirthPlace) if err != nil { return nil, err } cb, err := star.NatalChart(birth, birthTime, birthPlace) if err != nil { return nil, err } out, err := synastry.BuildReport(ca, cb, host.DisplayName, name, time.Now()) if err != nil { return nil, err } sum, _ := json.Marshal(out.Summary) det, _ := json.Marshal(out.Detail) rep, err := s.Invites.AcceptAtomic(ctx, inv.ID, guestUser, name, birth, birthTime, birthPlace, sum, det) if err != nil { return nil, err } return s.applyEntitlement(ctx, guestUser, rep) } // CreatePortrait builds and stores a portrait report. func (s *Service) CreatePortrait(ctx context.Context, userID, profileID uuid.UUID) (*model.GrowthReport, error) { p, err := s.Profiles.GetForUser(ctx, userID, profileID) if err != nil { return nil, errors.New("profile not found") } out := portrait.Build(p.BirthDate, p.DisplayName) sum, _ := json.Marshal(out.Summary) det, _ := json.Marshal(out.Detail) rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileID, nil, "portrait", sum, det) if err != nil { return nil, err } return s.applyEntitlement(ctx, userID, rep) } // CreateStar builds and stores a 星象性格 report. func (s *Service) CreateStar(ctx context.Context, userID, profileID uuid.UUID) (*model.GrowthReport, error) { p, err := s.Profiles.GetForUser(ctx, userID, profileID) if err != nil { return nil, errors.New("profile not found") } out, err := star.BuildWith(star.BuildOpts{ Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace, Name: p.DisplayName, AsOf: time.Now().In(time.FixedZone("CST", 8*3600)), }) if err != nil { return nil, err } sum, _ := json.Marshal(out.Summary) det, _ := json.Marshal(out.Detail) rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileID, nil, "star", sum, det) if err != nil { return nil, err } return s.applyEntitlement(ctx, userID, rep) } // CreateSynastry builds a multi-chart synastry report for two profiles. // asOf is the secondary-progression date (defaults to today CST when nil/zero). func (s *Service) CreateSynastry(ctx context.Context, userID, profileAID, profileBID uuid.UUID, asOf *time.Time) (*model.GrowthReport, error) { if profileAID == profileBID { return nil, errors.New("合盘需要两个不同档案") } pa, err := s.Profiles.GetForUser(ctx, userID, profileAID) if err != nil { return nil, errors.New("profile a not found") } pb, err := s.Profiles.GetForUser(ctx, userID, profileBID) if err != nil { // Allow geo-visible self profiles of other users (附近的人). pb, err = s.Profiles.GetByID(ctx, profileBID) if err != nil || !pb.GeoVisible || pb.Relation != "self" || pb.UserID == userID { return nil, errors.New("profile b not found") } } else if pb.Relation != "other" { return nil, errors.New("合盘对象须为 TA 档案或附近的人") } ca, err := star.NatalChart(pa.BirthDate, pa.BirthTime, pa.BirthPlace) if err != nil { return nil, err } cb, err := star.NatalChart(pb.BirthDate, pb.BirthTime, pb.BirthPlace) if err != nil { return nil, err } when := time.Now().In(time.FixedZone("CST", 8*3600)) if asOf != nil && !asOf.IsZero() { when = *asOf } out, err := synastry.BuildReport(ca, cb, pa.DisplayName, pb.DisplayName, when) if err != nil { return nil, err } sum, _ := json.Marshal(out.Summary) det, _ := json.Marshal(out.Detail) peer := profileBID rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileAID, &peer, "synastry", sum, det) if err != nil { return nil, err } return s.applyEntitlement(ctx, userID, rep) } // CreateRhythm builds and stores a 身心节律 report. func (s *Service) CreateRhythm(ctx context.Context, userID, profileID uuid.UUID) (*model.GrowthReport, error) { p, err := s.Profiles.GetForUser(ctx, userID, profileID) if err != nil { return nil, errors.New("profile not found") } out := rhythm.BuildWith(p.BirthDate, p.DisplayName, time.Now().In(time.FixedZone("CST", 8*3600))) sum, _ := json.Marshal(out.Summary) det, _ := json.Marshal(out.Detail) rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileID, nil, "rhythm", sum, det) if err != nil { return nil, err } return s.applyEntitlement(ctx, userID, rep) } // GetLatest returns cached report by profile+type(+peer); refreshes star/rhythm day tips. func (s *Service) GetLatest(ctx context.Context, userID, profileID uuid.UUID, typ string, peer *uuid.UUID) (*model.GrowthReport, error) { rep, err := s.Reports.GetLatest(ctx, userID, profileID, typ, peer) if err != nil { return nil, errors.New("report not found") } rep, _ = s.refreshTemporalIfNeeded(ctx, userID, rep) return s.applyEntitlement(ctx, userID, rep) } // Get returns a report with detail gated; refreshes star/rhythm day tips. func (s *Service) Get(ctx context.Context, userID, reportID uuid.UUID) (*model.GrowthReport, error) { rep, err := s.Reports.GetForUser(ctx, userID, reportID) if err != nil { return nil, errors.New("report not found") } rep, _ = s.refreshTemporalIfNeeded(ctx, userID, rep) return s.applyEntitlement(ctx, userID, rep) } // List returns recent reports with entitlement trimming. func (s *Service) List(ctx context.Context, userID uuid.UUID) ([]*model.GrowthReport, error) { items, err := s.Reports.ListForUser(ctx, userID, 50) if err != nil { return nil, err } out := make([]*model.GrowthReport, 0, len(items)) for i := range items { rep, err := s.applyEntitlement(ctx, userID, &items[i]) if err != nil { return nil, err } out = append(out, rep) } return out, nil } func (s *Service) applyEntitlement(ctx context.Context, userID uuid.UUID, rep *model.GrowthReport) (*model.GrowthReport, error) { deep, err := s.Reports.HasDeepAccess(ctx, userID, rep.ID) if err != nil { return nil, err } vip, err := s.Reports.HasActiveMembership(ctx, userID) if err != nil { return nil, err } rep.HasDeep = deep || vip if !rep.HasDeep { rep.Detail = nil } return rep, nil }