mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
The server module named github.com/hanzoai/team/server/v8 — a path that resolves to the retired workspace's repository, not this one — while server/public still published github.com/mattermost/mattermost/server/public. Both are github.com/hanzoteam/server/... now, which is where this code actually lives. That second half is what made the binary unbootable. Upstream's public path stayed claimed by upstream, mattermost-plugin-ai imported it, and the build linked public/model twice; public/plugin's gob.Register then ran twice under one name and panicked at init, before main. Reverting the rename traded the brand for a working binary. Rebranding the plugin alongside it needs no such trade: the agents fork is github.com/hanzoteam/agents/v2 against our public module, so no module named mattermost/mattermost is in the graph at all. Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and it is ours.
454 lines
13 KiB
Go
454 lines
13 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package sqlstore
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/hanzoteam/server/server/public/model"
|
|
"github.com/hanzoteam/server/server/v8/channels/store"
|
|
sq "github.com/mattermost/squirrel"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
var (
|
|
recapColumns = []string{
|
|
"Id",
|
|
"UserId",
|
|
"Title",
|
|
"CreateAt",
|
|
"UpdateAt",
|
|
"DeleteAt",
|
|
"ReadAt",
|
|
"ViewedAt",
|
|
"TotalMessageCount",
|
|
"Status",
|
|
"BotID",
|
|
"ScheduledRecapId",
|
|
"SkipReason",
|
|
}
|
|
|
|
recapChannelColumns = []string{
|
|
"Id",
|
|
"RecapId",
|
|
"ChannelId",
|
|
"ChannelName",
|
|
"Highlights",
|
|
"ActionItems",
|
|
"SourcePostIds",
|
|
"CreateAt",
|
|
}
|
|
)
|
|
|
|
type SqlRecapStore struct {
|
|
*SqlStore
|
|
|
|
recapSelectQuery sq.SelectBuilder
|
|
recapChannelSelectQuery sq.SelectBuilder
|
|
}
|
|
|
|
func newSqlRecapStore(sqlStore *SqlStore) store.RecapStore {
|
|
s := &SqlRecapStore{
|
|
SqlStore: sqlStore,
|
|
}
|
|
|
|
s.recapSelectQuery = s.getQueryBuilder().
|
|
Select(recapColumns...).
|
|
From("Recaps")
|
|
|
|
s.recapChannelSelectQuery = s.getQueryBuilder().
|
|
Select(recapChannelColumns...).
|
|
From("RecapChannels")
|
|
|
|
return s
|
|
}
|
|
|
|
func (s *SqlRecapStore) recapToMap(recap *model.Recap) map[string]any {
|
|
return map[string]any{
|
|
"Id": recap.Id,
|
|
"UserId": recap.UserId,
|
|
"Title": recap.Title,
|
|
"CreateAt": recap.CreateAt,
|
|
"UpdateAt": recap.UpdateAt,
|
|
"DeleteAt": recap.DeleteAt,
|
|
"ReadAt": recap.ReadAt,
|
|
"ViewedAt": recap.ViewedAt,
|
|
"TotalMessageCount": recap.TotalMessageCount,
|
|
"Status": recap.Status,
|
|
"BotID": recap.BotID,
|
|
"ScheduledRecapId": recap.ScheduledRecapId,
|
|
"SkipReason": recap.SkipReason,
|
|
}
|
|
}
|
|
|
|
func (s *SqlRecapStore) recapChannelToMap(rc *model.RecapChannel) (map[string]any, error) {
|
|
highlightsJSON, err := json.Marshal(rc.Highlights)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to marshal Highlights")
|
|
}
|
|
|
|
actionItemsJSON, err := json.Marshal(rc.ActionItems)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to marshal ActionItems")
|
|
}
|
|
|
|
sourcePostIdsJSON, err := json.Marshal(rc.SourcePostIds)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to marshal SourcePostIds")
|
|
}
|
|
|
|
return map[string]any{
|
|
"Id": rc.Id,
|
|
"RecapId": rc.RecapId,
|
|
"ChannelId": rc.ChannelId,
|
|
"ChannelName": rc.ChannelName,
|
|
"Highlights": string(highlightsJSON),
|
|
"ActionItems": string(actionItemsJSON),
|
|
"SourcePostIds": string(sourcePostIdsJSON),
|
|
"CreateAt": rc.CreateAt,
|
|
}, nil
|
|
}
|
|
|
|
func (s *SqlRecapStore) SaveRecap(recap *model.Recap) (*model.Recap, error) {
|
|
if err := s.saveRecapWithExecutor(s.GetMaster(), recap); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return recap, nil
|
|
}
|
|
|
|
func (s *SqlRecapStore) SaveRecapIfUnderDailyLimit(recap *model.Recap, since int64, limit int) (*model.Recap, error) {
|
|
// SERIALIZABLE prevents the COUNT/INSERT check from racing under READ COMMITTED;
|
|
// the retry layer retries the serialization failures this can surface.
|
|
tx, err := s.GetMaster().BeginWithIsolation(&sql.TxOptions{Isolation: sql.LevelSerializable})
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to begin transaction for SaveRecapIfUnderDailyLimit")
|
|
}
|
|
defer finalizeTransactionX(tx, &err)
|
|
|
|
count, err := s.countForUserSinceWithExecutor(tx, recap.UserId, since)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if count >= int64(limit) {
|
|
return nil, store.NewErrLimitExceeded("recaps_per_day", int(count), fmt.Sprintf("userId=%s limit=%d", recap.UserId, limit))
|
|
}
|
|
|
|
if err = s.saveRecapWithExecutor(tx, recap); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err = tx.Commit(); err != nil {
|
|
return nil, errors.Wrap(err, "failed to commit transaction for SaveRecapIfUnderDailyLimit")
|
|
}
|
|
|
|
return recap, nil
|
|
}
|
|
|
|
func (s *SqlRecapStore) saveRecapWithExecutor(executor sqlxExecutor, recap *model.Recap) error {
|
|
query := s.getQueryBuilder().
|
|
Insert("Recaps").
|
|
SetMap(s.recapToMap(recap))
|
|
|
|
if _, err := executor.ExecBuilder(query); err != nil {
|
|
return errors.Wrap(err, "failed to save Recap")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlRecapStore) GetRecap(id string) (*model.Recap, error) {
|
|
var recap model.Recap
|
|
query := s.recapSelectQuery.Where(sq.Eq{"Id": id, "DeleteAt": 0})
|
|
|
|
if err := s.GetReplica().GetBuilder(&recap, query); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, store.NewErrNotFound("Recap", id)
|
|
}
|
|
return nil, errors.Wrapf(err, "failed to get Recap with id=%s", id)
|
|
}
|
|
|
|
return &recap, nil
|
|
}
|
|
|
|
func (s *SqlRecapStore) GetRecapsForUser(userId string, page, perPage int) ([]*model.Recap, error) {
|
|
offset := page * perPage
|
|
var recaps []*model.Recap
|
|
|
|
query := s.recapSelectQuery.
|
|
Where(sq.Eq{"UserId": userId, "DeleteAt": 0}).
|
|
Where(sq.NotEq{"Status": model.RecapStatusSkipped}). // Skipped recaps are internal audit records, not client-facing.
|
|
OrderBy("CreateAt DESC").
|
|
Limit(uint64(perPage)).
|
|
Offset(uint64(offset))
|
|
|
|
if err := s.GetReplica().SelectBuilder(&recaps, query); err != nil {
|
|
return nil, errors.Wrapf(err, "failed to get Recaps for userId=%s", userId)
|
|
}
|
|
|
|
return recaps, nil
|
|
}
|
|
|
|
func (s *SqlRecapStore) UpdateRecap(recap *model.Recap) (*model.Recap, error) {
|
|
query := s.getQueryBuilder().
|
|
Update("Recaps").
|
|
SetMap(map[string]any{
|
|
"Title": recap.Title,
|
|
"UpdateAt": recap.UpdateAt,
|
|
"TotalMessageCount": recap.TotalMessageCount,
|
|
"Status": recap.Status,
|
|
"ReadAt": recap.ReadAt,
|
|
"ViewedAt": recap.ViewedAt,
|
|
}).
|
|
Where(sq.Eq{"Id": recap.Id})
|
|
|
|
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
|
|
return nil, errors.Wrapf(err, "failed to update Recap with id=%s", recap.Id)
|
|
}
|
|
|
|
return recap, nil
|
|
}
|
|
|
|
func (s *SqlRecapStore) UpdateRecapStatus(id, status string) error {
|
|
updateAt := model.GetMillis()
|
|
|
|
query := s.getQueryBuilder().
|
|
Update("Recaps").
|
|
SetMap(map[string]any{
|
|
"Status": status,
|
|
"UpdateAt": updateAt,
|
|
}).
|
|
Where(sq.Eq{"Id": id})
|
|
|
|
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
|
|
return errors.Wrapf(err, "failed to update Recap status for id=%s", id)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MarkRecapSkipped flips a still-pending recap to skipped. Scoped to pending so a
|
|
// recap a worker has already started processing is never clobbered.
|
|
func (s *SqlRecapStore) MarkRecapSkipped(id, reason string) error {
|
|
query := s.getQueryBuilder().
|
|
Update("Recaps").
|
|
SetMap(map[string]any{
|
|
"Status": model.RecapStatusSkipped,
|
|
"SkipReason": reason,
|
|
"UpdateAt": model.GetMillis(),
|
|
}).
|
|
Where(sq.Eq{"Id": id, "Status": model.RecapStatusPending})
|
|
|
|
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
|
|
return errors.Wrapf(err, "failed to mark Recap as skipped for id=%s", id)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlRecapStore) MarkRecapAsRead(id string) error {
|
|
now := model.GetMillis()
|
|
|
|
query := s.getQueryBuilder().
|
|
Update("Recaps").
|
|
SetMap(map[string]any{
|
|
"ReadAt": now,
|
|
"UpdateAt": now,
|
|
}).
|
|
Where(sq.Eq{"Id": id, "ReadAt": 0})
|
|
|
|
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
|
|
return errors.Wrapf(err, "failed to mark Recap as read for id=%s", id)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlRecapStore) MarkRecapsAsViewed(userId string, statuses []string) ([]string, error) {
|
|
if len(statuses) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
now := model.GetMillis()
|
|
|
|
query, args, err := s.getQueryBuilder().
|
|
Update("Recaps").
|
|
SetMap(map[string]any{
|
|
"ViewedAt": now,
|
|
"UpdateAt": now,
|
|
}).
|
|
Where(sq.Eq{"UserId": userId, "ViewedAt": 0, "DeleteAt": 0, "Status": statuses}).
|
|
Suffix("RETURNING Id").
|
|
ToSql()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to build MarkRecapsAsViewed query")
|
|
}
|
|
|
|
var ids []string
|
|
if err := s.GetMaster().Select(&ids, query, args...); err != nil {
|
|
return nil, errors.Wrapf(err, "failed to mark recaps as viewed for userId=%s", userId)
|
|
}
|
|
|
|
return ids, nil
|
|
}
|
|
|
|
func (s *SqlRecapStore) DeleteRecap(id string) error {
|
|
deleteAt := model.GetMillis()
|
|
|
|
query := s.getQueryBuilder().
|
|
Update("Recaps").
|
|
SetMap(map[string]any{
|
|
"DeleteAt": deleteAt,
|
|
}).
|
|
Where(sq.Eq{"Id": id})
|
|
|
|
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
|
|
return errors.Wrapf(err, "failed to delete Recap with id=%s", id)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlRecapStore) DeleteRecapChannels(recapId string) error {
|
|
query := s.getQueryBuilder().
|
|
Delete("RecapChannels").
|
|
Where(sq.Eq{"RecapId": recapId})
|
|
|
|
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
|
|
return errors.Wrapf(err, "failed to delete RecapChannels for recapId=%s", recapId)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlRecapStore) SaveRecapChannel(recapChannel *model.RecapChannel) error {
|
|
rcMap, err := s.recapChannelToMap(recapChannel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
query := s.getQueryBuilder().
|
|
Insert("RecapChannels").
|
|
SetMap(rcMap)
|
|
|
|
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
|
|
return errors.Wrap(err, "failed to save RecapChannel")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlRecapStore) GetRecapChannelsByRecapId(recapId string) ([]*model.RecapChannel, error) {
|
|
query := s.recapChannelSelectQuery.
|
|
Where(sq.Eq{"RecapId": recapId}).
|
|
OrderBy("CreateAt ASC")
|
|
|
|
var dbRecapChannels []struct {
|
|
Id string
|
|
RecapId string
|
|
ChannelId string
|
|
ChannelName string
|
|
Highlights string
|
|
ActionItems string
|
|
SourcePostIds string
|
|
CreateAt int64
|
|
}
|
|
|
|
if err := s.GetReplica().SelectBuilder(&dbRecapChannels, query); err != nil {
|
|
return nil, errors.Wrapf(err, "failed to get RecapChannels for recapId=%s", recapId)
|
|
}
|
|
|
|
recapChannels := make([]*model.RecapChannel, 0, len(dbRecapChannels))
|
|
for _, dbRC := range dbRecapChannels {
|
|
rc := &model.RecapChannel{
|
|
Id: dbRC.Id,
|
|
RecapId: dbRC.RecapId,
|
|
ChannelId: dbRC.ChannelId,
|
|
ChannelName: dbRC.ChannelName,
|
|
CreateAt: dbRC.CreateAt,
|
|
}
|
|
|
|
// Unmarshal JSON strings back to arrays
|
|
if err := json.Unmarshal([]byte(dbRC.Highlights), &rc.Highlights); err != nil {
|
|
return nil, errors.Wrap(err, fmt.Sprintf("failed to unmarshal Highlights for recapChannel id=%s", dbRC.Id))
|
|
}
|
|
|
|
if err := json.Unmarshal([]byte(dbRC.ActionItems), &rc.ActionItems); err != nil {
|
|
return nil, errors.Wrap(err, fmt.Sprintf("failed to unmarshal ActionItems for recapChannel id=%s", dbRC.Id))
|
|
}
|
|
|
|
if err := json.Unmarshal([]byte(dbRC.SourcePostIds), &rc.SourcePostIds); err != nil {
|
|
return nil, errors.Wrap(err, fmt.Sprintf("failed to unmarshal SourcePostIds for recapChannel id=%s", dbRC.Id))
|
|
}
|
|
|
|
recapChannels = append(recapChannels, rc)
|
|
}
|
|
|
|
return recapChannels, nil
|
|
}
|
|
|
|
// CountForUserSince returns count of recaps created by user since given timestamp.
|
|
// Excludes skipped recaps from the count, but still counts soft-deleted recaps
|
|
// because they already consumed AI usage.
|
|
func (s *SqlRecapStore) CountForUserSince(userId string, since int64) (int64, error) {
|
|
return s.countForUserSinceWithExecutor(s.GetReplica(), userId, since)
|
|
}
|
|
|
|
func (s *SqlRecapStore) countForUserSinceWithExecutor(executor sqlxExecutor, userId string, since int64) (int64, error) {
|
|
query := s.getQueryBuilder().
|
|
Select("COUNT(*)").
|
|
From("Recaps").
|
|
Where(sq.Eq{"UserId": userId}).
|
|
Where(sq.GtOrEq{"CreateAt": since}).
|
|
Where(sq.NotEq{"Status": model.RecapStatusSkipped}) // Don't count skipped recaps
|
|
|
|
var count int64
|
|
err := executor.GetBuilder(&count, query)
|
|
if err != nil {
|
|
return 0, errors.Wrap(err, "failed to count recaps for user since timestamp")
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
func (s *SqlRecapStore) SumTotalMessageCountForUserSince(userId string, since int64) (int64, error) {
|
|
query := s.getQueryBuilder().
|
|
Select("COALESCE(SUM(TotalMessageCount), 0)").
|
|
From("Recaps").
|
|
Where(sq.Eq{"UserId": userId}).
|
|
Where(sq.GtOrEq{"CreateAt": since}).
|
|
Where(sq.NotEq{"Status": model.RecapStatusSkipped})
|
|
|
|
var total int64
|
|
err := s.GetReplica().GetBuilder(&total, query)
|
|
if err != nil {
|
|
return 0, errors.Wrap(err, "failed to sum recap message count for user since timestamp")
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
// GetLastCompletedManualRecap returns the most recent completed manual recap for user.
|
|
// Manual recap = ScheduledRecapId is empty. Used for cooldown checking, including
|
|
// soft-deleted recaps because deleting a recap should not bypass cooldown.
|
|
// Returns nil, nil if no manual recap exists.
|
|
func (s *SqlRecapStore) GetLastCompletedManualRecap(userId string) (*model.Recap, error) {
|
|
var recap model.Recap
|
|
query := s.recapSelectQuery.
|
|
Where(sq.Eq{"UserId": userId}).
|
|
Where(sq.Eq{"Status": model.RecapStatusCompleted}).
|
|
Where(sq.Or{sq.Eq{"ScheduledRecapId": ""}, sq.Expr("ScheduledRecapId IS NULL")}). // Manual = no scheduled recap ID (NULL for pre-migration rows)
|
|
OrderBy("CreateAt DESC").
|
|
Limit(1)
|
|
|
|
err := s.GetReplica().GetBuilder(&recap, query)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil // No manual recap found - not an error
|
|
}
|
|
return nil, errors.Wrap(err, "failed to get last completed manual recap")
|
|
}
|
|
return &recap, nil
|
|
}
|