mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
* [MM-59089] Add a compliance export constant (#27919) * add a useful constant * i18n * another constant * another i18n * [MM-60422] Add GetChannelsWithActivityDuring (#28301) * modify GetUsersInChannelDuring to accept a slice of channelIds * add GetChannelsWithActivityDuring * add compliance export progress message; remove unused custom status * linting * tests running too fast * add batch size config settings * add store tests * linting * empty commit * i18n changes * fix i18n ordering * MM-60570 - Server-side changes consolidating the export CLI with server/ent code (#28640) * add an i18n field; add the CLI's export directory * int64 -> int * Add UntilUpdateAt for MessageExport and AnalyticsPostCount to merge * remove now-unused i18n strings * add TranslationsPreInitFromBuffer to allow CLI to use i18n * use GetBuilder to simplify; rename TranslationsPreInitFromFileBytes * [MM-59089] Improve compliance export timings (#1733 - Enterprise repo) * MM-60422 - Performance and logic fixes for Compliance Exports (#1757 - Enterprise repo) * MM-60570 - Enterprise-side changes consolidating the export CLI with server/ent code (#1769 - Enterprise repo) * merge conflicts; missed file from ent branch * MM-61038 - Add an option to sqlstore.New (#28702) remove useless comment add test add an option to sqlstore.New * MM-60976: Remove RunExport command from Mattermost binary (#28805) * remove RunExport command from mattermost binary * remove the code it was calling * fix i18n * remove test (was only testing license, not functionality) * empty commit * fix flaky GetChannelsWithActivityDuring test * MM-60063: Dedicated Export Filestore fix, redo of #1772 (enterprise) (#28803) * redo filestore fix #1772 (enterprise repo) on top of MM-59966 feature * add new e2e tests for export filestore * golint * ok, note to self: shadowing bad, actually (when there's a defer) * empty commit * MM-61137 - Message export: Support 7.8.11 era dbs (#28824) * support 7.8.11 era dbs by wrapping the store using only what we need * fix flaky GetChannelsWithActivityDuring test * add a comment * only need to define the MEFileInfoStore (the one that'll be overridden) * blank commit * MM-60974 - Message Export: Add performance metrics (#28836) * support 7.8.11 era dbs by wrapping the store using only what we need * fix flaky GetChannelsWithActivityDuring test * add a comment * only need to define the MEFileInfoStore (the one that'll be overridden) * performance metrics * cleanup unneeded named returns * blank commit * MM-60975 - Message export: Add startTime and endTime to export folder name (#28840) * support 7.8.11 era dbs by wrapping the store using only what we need * fix flaky GetChannelsWithActivityDuring test * add a comment * only need to define the MEFileInfoStore (the one that'll be overridden) * performance metrics * output startTime and endTime in export folder * empty commit * merge conflict * MM-60978 - Message export: Improve xml fields; fix delete semantics (#28873) * support 7.8.11 era dbs by wrapping the store using only what we need * fix flaky GetChannelsWithActivityDuring test * add a comment * only need to define the MEFileInfoStore (the one that'll be overridden) * performance metrics * output startTime and endTime in export folder * empty commit * add xml fields, omit when empty, tests * fix delete semantics; test (and test for update semantics) * clarify comments * simplify edited post detection, now there's no edge case. * add some spacing to help fast running tests * merge conflicts/updates needed for new deleted post semantics * linting; fixing tests from upstream merge * use SafeDereference * linting * stronger typing; better wrapped errors; better formatting * blank commit * goimports formatting * fix merge mistake * minor fixes due to changes in master * MM-61755 - Simplifying and Support reporting to the db from the CLI (#29281) * finally clean up JobData struct and stringMap; prep for CLI using db * and now simplify using StringMapToJobDataWithZeroValues * remove unused fn * create JobDataExported; clean up errors * MM-60176 - Message Export: Global relay cleanup (#29168) * move global relay logic into global_relay_export * blank commit * blank commit * improve errors * MM-60693 - Refactor CSV to use same codepath as Actiance (#29191) * move global relay logic into global_relay_export * blank commit * refactor (and simplify) ExportParams into shared * blank commit * remove unused fn * csv now uses pre-calculated joins/leaves like actiance * improve errors * remove nil post check; remove ignoredPosts metric * remove unneeded copy * MM-61696 - Refactor GlobalRelay to use same codepath as Actiance (#29225) * move global relay logic into global_relay_export * blank commit * refactor (and simplify) ExportParams into shared * blank commit * remove unused fn * csv now uses pre-calculated joins/leaves like actiance * remove newly unneeded function and its test. goodbye. * refactor GetPostAttachments for csv + global relay to share * refactor global_relay_export and fix tests (no changes to output) * improve errors * remove nil post check; remove ignoredPosts metric * remove unneeded copy * remove unneeded nil check * PR comments * MM-61715 - Generalize e2e to all export types 🤖 (#29369) * move global relay logic into global_relay_export * blank commit * refactor (and simplify) ExportParams into shared * blank commit * remove unused fn * csv now uses pre-calculated joins/leaves like actiance * remove newly unneeded function and its test. goodbye. * refactor GetPostAttachments for csv + global relay to share * refactor global_relay_export and fix tests (no changes to output) * improve errors * remove nil post check; remove ignoredPosts metric * remove unneeded copy * remove unneeded nil check * PR comments * refactor isDeletedMsg for all export types * fix start and endtime, nasty csv createAt bug; bring closer to Actiance * align unit tests with new logic (e.g. starttime / endtime) * refactor a TimestampConvert fn for code + tests * bug: pass templates to global relay (hurray for e2e tests, otherwise...) * add global relay zip to allowed list (only for tests) * test helpers * new templates for e2e tests * e2e tests... phew. * linting * merge conflicts * unexport PostToRow; add test helper marker * cleanup, shortening, thanks to PR comments * MM-61972 - Generalize export data path - Actiance (#29399) * extract and generalize the export data generation functions * finish moving test (bc of previous extraction) * lift a function from common -> shared (to break an import cycle) * actiance now takes general export data, processes it into actiance data * bring tests in line with correct sorting rules (upadateAt, messageId) * fixups, PR comments * turn strings.Repeat into a more descriptive const amended: one letter fix; bad rebase * MM-62009 - e2e clock heisenbug (#29434) * consolidate assertions; output debuggable diffs (keeping for future) * refactor test output generator to generators file * waitUntilZeroPosts + pass through until to job = fix all clock issues * simplify messages to model.NewId(); remove unneeded waitUntilZeroPosts * model.NewId() -> storetest.NewTestID() * MM-61980 - Generalize export data path - CSV (#29482) * simple refactoring * increase sleep times for (very) rare test failures * add extra information to the generic export for CSV * adj Actiance to handle new generic export (no difference in its output) * no longer need mergePosts (yay), move getJoinLeavePosts for everyone * adjust tests for new csv semantics (detailed in summary) * and need to add the new exported data to the export_data_tests * rearrange csv writing to happen after data export (more logical) * linting * remove debug statements * figured out what was wrong with global relay e2e test 3; solid now * PR comments * MM-61718 - Generalize export data path - Global Relay (#29508) * move global relay over to using the generalized export data * performance pass -- not much can be done * Update server/enterprise/message_export/global_relay_export/global_relay_export.go Co-authored-by: Claudio Costa <[email protected]> --------- Co-authored-by: Claudio Costa <[email protected]> * MM-62058 - Align CSV with Actiance (#29551) * refactoring actiance files and var names for clarity * bug found in exported attachments (we used to miss some start/ends) * changes needed for actiance due to new generic exports * bringing CSV up to actiance standards * fixing global relay b/c of new semantics (adding a note on an edge case) * aligning e2e tests, adding comments to clarify what is expected/tested * necessary changes; 1 more test for added functionality (ignoreDeleted) * comment style * MM-62059 - Align Global Relay with Actiance/CSV; many fixes (#29665) * core logic changes to general export_data and the specific export paths * unit tests and e2e tests, covering all new edge cases and all logic * linting * better var naming, const value, and cleaning up functions calls * MM-62436 - Temporarily skip cypress tests that require download link (#29772) --------- Co-authored-by: Claudio Costa <[email protected]>
348 lines
12 KiB
Go
348 lines
12 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package sqlstore
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
|
|
sq "github.com/mattermost/squirrel"
|
|
"github.com/pkg/errors"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/mattermost/mattermost/server/public/shared/request"
|
|
"github.com/mattermost/mattermost/server/v8/channels/store"
|
|
)
|
|
|
|
type SqlComplianceStore struct {
|
|
*SqlStore
|
|
}
|
|
|
|
func newSqlComplianceStore(sqlStore *SqlStore) store.ComplianceStore {
|
|
return &SqlComplianceStore{sqlStore}
|
|
}
|
|
|
|
func (s SqlComplianceStore) Save(compliance *model.Compliance) (*model.Compliance, error) {
|
|
compliance.PreSave()
|
|
if err := compliance.IsValid(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// DESC is a keyword
|
|
desc := s.toReserveCase("desc")
|
|
|
|
query := `INSERT INTO Compliances (Id, CreateAt, UserId, Status, Count, ` + desc + `, Type, StartAt, EndAt, Keywords, Emails)
|
|
VALUES
|
|
(:Id, :CreateAt, :UserId, :Status, :Count, :Desc, :Type, :StartAt, :EndAt, :Keywords, :Emails)`
|
|
if _, err := s.GetMaster().NamedExec(query, compliance); err != nil {
|
|
return nil, errors.Wrap(err, "failed to save Compliance")
|
|
}
|
|
return compliance, nil
|
|
}
|
|
|
|
func (s SqlComplianceStore) Update(compliance *model.Compliance) (*model.Compliance, error) {
|
|
if err := compliance.IsValid(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
query := s.getQueryBuilder().
|
|
Update("Compliances").
|
|
Set("CreateAt", compliance.CreateAt).
|
|
Set("UserId", compliance.UserId).
|
|
Set("Status", compliance.Status).
|
|
Set("Count", compliance.Count).
|
|
Set("Type", compliance.Type).
|
|
Set("StartAt", compliance.StartAt).
|
|
Set("EndAt", compliance.EndAt).
|
|
Set("Keywords", compliance.Keywords).
|
|
Set("Emails", compliance.Emails).
|
|
Where(sq.Eq{"Id": compliance.Id})
|
|
|
|
// DESC is a keyword
|
|
query = query.Set(s.toReserveCase("desc"), compliance.Desc)
|
|
|
|
queryString, args, err := query.ToSql()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "compliances_tosql")
|
|
}
|
|
|
|
res, err := s.GetMaster().Exec(queryString, args...)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to update Compliance")
|
|
}
|
|
count, err := res.RowsAffected()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "error while getting rows_affected")
|
|
}
|
|
if count > 1 {
|
|
return nil, fmt.Errorf("unexpected count while updating compliances: count=%d, Id=%s", count, compliance.Id)
|
|
}
|
|
return compliance, nil
|
|
}
|
|
|
|
func (s SqlComplianceStore) GetAll(offset, limit int) (model.Compliances, error) {
|
|
query := "SELECT * FROM Compliances ORDER BY CreateAt DESC LIMIT ? OFFSET ?"
|
|
compliances := model.Compliances{}
|
|
if err := s.GetReplica().Select(&compliances, query, limit, offset); err != nil {
|
|
return nil, errors.Wrap(err, "failed to find all Compliances")
|
|
}
|
|
return compliances, nil
|
|
}
|
|
|
|
func (s SqlComplianceStore) Get(id string) (*model.Compliance, error) {
|
|
var compliance model.Compliance
|
|
if err := s.GetReplica().Get(&compliance, `SELECT * FROM Compliances WHERE Id = ?`, id); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, store.NewErrNotFound("Compliances", id)
|
|
}
|
|
return nil, errors.Wrapf(err, "failed to get Compliance with id=%s", id)
|
|
}
|
|
if compliance.Id == "" {
|
|
return nil, store.NewErrNotFound("Compliance", id)
|
|
}
|
|
return &compliance, nil
|
|
}
|
|
|
|
func (s SqlComplianceStore) ComplianceExport(job *model.Compliance, cursor model.ComplianceExportCursor, limit int) ([]*model.CompliancePost, model.ComplianceExportCursor, error) {
|
|
keywordQuery := ""
|
|
var argsKeywords []any
|
|
keywords := strings.Fields(strings.TrimSpace(strings.ToLower(strings.Replace(job.Keywords, ",", " ", -1))))
|
|
if len(keywords) > 0 {
|
|
clauses := make([]string, len(keywords))
|
|
|
|
for i, keyword := range keywords {
|
|
keyword = sanitizeSearchTerm(keyword, "\\")
|
|
clauses[i] = "LOWER(Posts.Message) LIKE ?"
|
|
argsKeywords = append(argsKeywords, "%"+keyword+"%")
|
|
}
|
|
|
|
keywordQuery = "AND (" + strings.Join(clauses, " OR ") + ")"
|
|
}
|
|
|
|
emailQuery := ""
|
|
var argsEmails []any
|
|
emails := strings.Fields(strings.TrimSpace(strings.ToLower(strings.Replace(job.Emails, ",", " ", -1))))
|
|
if len(emails) > 0 {
|
|
clauses := make([]string, len(emails))
|
|
|
|
for i, email := range emails {
|
|
clauses[i] = "Users.Email = ?"
|
|
argsEmails = append(argsEmails, email)
|
|
}
|
|
|
|
emailQuery = "AND (" + strings.Join(clauses, " OR ") + ")"
|
|
}
|
|
|
|
// The idea is to first iterate over the channel posts, and then when we run out of those,
|
|
// start iterating over the direct message posts.
|
|
|
|
channelPosts := []*model.CompliancePost{}
|
|
channelsQuery := ""
|
|
var argsChannelsQuery []any
|
|
if !cursor.ChannelsQueryCompleted {
|
|
if cursor.LastChannelsQueryPostCreateAt == 0 {
|
|
cursor.LastChannelsQueryPostCreateAt = job.StartAt
|
|
}
|
|
// append the named parameters of SQL query in the correct order to argsChannelsQuery
|
|
argsChannelsQuery = append(argsChannelsQuery, cursor.LastChannelsQueryPostCreateAt, cursor.LastChannelsQueryPostCreateAt, cursor.LastChannelsQueryPostID, job.EndAt)
|
|
argsChannelsQuery = append(argsChannelsQuery, argsEmails...)
|
|
argsChannelsQuery = append(argsChannelsQuery, argsKeywords...)
|
|
argsChannelsQuery = append(argsChannelsQuery, limit)
|
|
channelsQuery = `
|
|
SELECT
|
|
Teams.Name AS TeamName,
|
|
Teams.DisplayName AS TeamDisplayName,
|
|
Channels.Name AS ChannelName,
|
|
Channels.DisplayName AS ChannelDisplayName,
|
|
Channels.Type AS ChannelType,
|
|
Users.Username AS UserUsername,
|
|
Users.Email AS UserEmail,
|
|
Users.Nickname AS UserNickname,
|
|
Posts.Id AS PostId,
|
|
Posts.CreateAt AS PostCreateAt,
|
|
Posts.UpdateAt AS PostUpdateAt,
|
|
Posts.DeleteAt AS PostDeleteAt,
|
|
Posts.RootId AS PostRootId,
|
|
Posts.OriginalId AS PostOriginalId,
|
|
Posts.Message AS PostMessage,
|
|
Posts.Type AS PostType,
|
|
Posts.Props AS PostProps,
|
|
Posts.Hashtags AS PostHashtags,
|
|
Posts.FileIds AS PostFileIds,
|
|
Bots.UserId IS NOT NULL AS IsBot
|
|
FROM
|
|
Teams,
|
|
Channels,
|
|
Users,
|
|
Posts
|
|
LEFT JOIN
|
|
Bots ON Bots.UserId = Posts.UserId
|
|
WHERE
|
|
Teams.Id = Channels.TeamId
|
|
AND Posts.ChannelId = Channels.Id
|
|
AND Posts.UserId = Users.Id
|
|
AND (
|
|
Posts.CreateAt > ?
|
|
OR (Posts.CreateAt = ? AND Posts.Id > ?)
|
|
)
|
|
AND Posts.CreateAt < ?
|
|
` + emailQuery + `
|
|
` + keywordQuery + `
|
|
ORDER BY Posts.CreateAt, Posts.Id
|
|
LIMIT ?`
|
|
if err := s.GetReplica().Select(&channelPosts, channelsQuery, argsChannelsQuery...); err != nil {
|
|
return nil, cursor, errors.Wrap(err, "unable to export compliance")
|
|
}
|
|
if len(channelPosts) < limit {
|
|
cursor.ChannelsQueryCompleted = true
|
|
} else {
|
|
cursor.LastChannelsQueryPostCreateAt = channelPosts[len(channelPosts)-1].PostCreateAt
|
|
cursor.LastChannelsQueryPostID = channelPosts[len(channelPosts)-1].PostId
|
|
}
|
|
}
|
|
|
|
directMessagePosts := []*model.CompliancePost{}
|
|
directMessagesQuery := ""
|
|
var argsDirectMessagesQuery []any
|
|
if !cursor.DirectMessagesQueryCompleted && len(channelPosts) < limit {
|
|
if cursor.LastDirectMessagesQueryPostCreateAt == 0 {
|
|
cursor.LastDirectMessagesQueryPostCreateAt = job.StartAt
|
|
}
|
|
// append the named parameters of SQL query in the correct order to argsDirectMessagesQuery
|
|
argsDirectMessagesQuery = append(argsDirectMessagesQuery, cursor.LastDirectMessagesQueryPostCreateAt, cursor.LastDirectMessagesQueryPostCreateAt, cursor.LastDirectMessagesQueryPostID, job.EndAt)
|
|
argsDirectMessagesQuery = append(argsDirectMessagesQuery, argsEmails...)
|
|
argsDirectMessagesQuery = append(argsDirectMessagesQuery, argsKeywords...)
|
|
argsDirectMessagesQuery = append(argsDirectMessagesQuery, limit-len(channelPosts))
|
|
directMessagesQuery = `
|
|
SELECT
|
|
'direct-messages' AS TeamName,
|
|
'Direct Messages' AS TeamDisplayName,
|
|
Channels.Name AS ChannelName,
|
|
Channels.DisplayName AS ChannelDisplayName,
|
|
Channels.Type AS ChannelType,
|
|
Users.Username AS UserUsername,
|
|
Users.Email AS UserEmail,
|
|
Users.Nickname AS UserNickname,
|
|
Posts.Id AS PostId,
|
|
Posts.CreateAt AS PostCreateAt,
|
|
Posts.UpdateAt AS PostUpdateAt,
|
|
Posts.DeleteAt AS PostDeleteAt,
|
|
Posts.RootId AS PostRootId,
|
|
Posts.OriginalId AS PostOriginalId,
|
|
Posts.Message AS PostMessage,
|
|
Posts.Type AS PostType,
|
|
Posts.Props AS PostProps,
|
|
Posts.Hashtags AS PostHashtags,
|
|
Posts.FileIds AS PostFileIds,
|
|
Bots.UserId IS NOT NULL AS IsBot
|
|
FROM
|
|
Channels,
|
|
Users,
|
|
Posts
|
|
LEFT JOIN
|
|
Bots ON Bots.UserId = Posts.UserId
|
|
WHERE
|
|
Channels.TeamId = ''
|
|
AND Posts.ChannelId = Channels.Id
|
|
AND Posts.UserId = Users.Id
|
|
AND (
|
|
Posts.CreateAt > ?
|
|
OR (Posts.CreateAt = ? AND Posts.Id > ?)
|
|
)
|
|
AND Posts.CreateAt < ?
|
|
` + emailQuery + `
|
|
` + keywordQuery + `
|
|
ORDER BY Posts.CreateAt, Posts.Id
|
|
LIMIT ?`
|
|
|
|
if err := s.GetReplica().Select(&directMessagePosts, directMessagesQuery, argsDirectMessagesQuery...); err != nil {
|
|
return nil, cursor, errors.Wrap(err, "unable to export compliance")
|
|
}
|
|
if len(directMessagePosts) < limit {
|
|
cursor.DirectMessagesQueryCompleted = true
|
|
} else {
|
|
cursor.LastDirectMessagesQueryPostCreateAt = directMessagePosts[len(directMessagePosts)-1].PostCreateAt
|
|
cursor.LastDirectMessagesQueryPostID = directMessagePosts[len(directMessagePosts)-1].PostId
|
|
}
|
|
}
|
|
|
|
return append(channelPosts, directMessagePosts...), cursor, nil
|
|
}
|
|
|
|
func (s SqlComplianceStore) MessageExport(c request.CTX, cursor model.MessageExportCursor, limit int) ([]*model.MessageExport, model.MessageExportCursor, error) {
|
|
caseStmt, caseArgs, caseErr := sq.Case().
|
|
When(
|
|
sq.Eq{"Channels.Type": model.ChannelTypeDirect},
|
|
"'Direct Message'",
|
|
).
|
|
When(
|
|
sq.Eq{"Channels.Type": model.ChannelTypeGroup},
|
|
"'Group Message'",
|
|
).
|
|
Else("Channels.DisplayName").ToSql()
|
|
if caseErr != nil {
|
|
return nil, cursor, errors.Wrap(caseErr, "unable to construct case statement")
|
|
}
|
|
|
|
builder := s.getQueryBuilder().Select(`Posts.Id AS PostId,
|
|
Posts.CreateAt AS PostCreateAt,
|
|
Posts.UpdateAt AS PostUpdateAt,
|
|
Posts.DeleteAt AS PostDeleteAt,
|
|
Posts.EditAt AS PostEditAt,
|
|
Posts.Message AS PostMessage,
|
|
Posts.Type AS PostType,
|
|
Posts.Props AS PostProps,
|
|
Posts.OriginalId AS PostOriginalId,
|
|
Posts.RootId AS PostRootId,
|
|
Posts.FileIds AS PostFileIds,
|
|
Teams.Id AS TeamId,
|
|
Teams.Name AS TeamName,
|
|
Teams.DisplayName AS TeamDisplayName,
|
|
Channels.Id AS ChannelId,
|
|
Channels.Name AS ChannelName,
|
|
Channels.Type AS ChannelType,
|
|
Users.Id AS UserId,
|
|
Users.Email AS UserEmail,
|
|
Users.Username,
|
|
Bots.UserId IS NOT NULL AS IsBot`).
|
|
Column(caseStmt+" AS ChannelDisplayName", caseArgs...).
|
|
From("Posts").
|
|
JoinClause("LEFT OUTER JOIN Channels ON Posts.ChannelId = Channels.Id").
|
|
JoinClause("LEFT OUTER JOIN Teams ON Channels.TeamId = Teams.Id").
|
|
JoinClause("LEFT OUTER JOIN Users ON Posts.UserId = Users.Id").
|
|
LeftJoin("Bots ON Bots.UserId = Posts.UserId").
|
|
Where(sq.And{
|
|
sq.Or{
|
|
sq.Gt{"Posts.UpdateAt": cursor.LastPostUpdateAt},
|
|
sq.And{
|
|
sq.Eq{"Posts.UpdateAt": cursor.LastPostUpdateAt},
|
|
sq.Gt{"Posts.Id": cursor.LastPostId},
|
|
},
|
|
},
|
|
sq.NotLike{"Posts.Type": "system_%"},
|
|
}).
|
|
OrderBy("PostUpdateAt, PostId").
|
|
Limit(uint64(limit))
|
|
|
|
if cursor.UntilUpdateAt > 0 {
|
|
builder = builder.Where(sq.LtOrEq{"Posts.UpdateAt": cursor.UntilUpdateAt})
|
|
}
|
|
|
|
query, args, err := builder.ToSql()
|
|
if err != nil {
|
|
return nil, cursor, errors.Wrap(err, "unable to construct query to export messages")
|
|
}
|
|
|
|
cposts := []*model.MessageExport{}
|
|
if err := s.GetReplica().SelectCtx(c.Context(), &cposts, query, args...); err != nil {
|
|
return nil, cursor, errors.Wrap(err, "unable to export messages")
|
|
}
|
|
if len(cposts) > 0 {
|
|
cursor.LastPostUpdateAt = *cposts[len(cposts)-1].PostUpdateAt
|
|
cursor.LastPostId = *cposts[len(cposts)-1].PostId
|
|
}
|
|
return cposts, cursor, nil
|
|
}
|