go.charczuk.com@v0.0.0-20240327042549-bc490516bd1a/projects/chirp/pkg/model/manager.go (about)

     1  /*
     2  
     3  Copyright (c) 2023 - Present. Will Charczuk. All rights reserved.
     4  Use of this source code is governed by a MIT license that can be found in the LICENSE file at the root of the repository.
     5  
     6  */
     7  
     8  package model
     9  
    10  import (
    11  	"context"
    12  	"fmt"
    13  	"time"
    14  
    15  	"go.charczuk.com/sdk/apputil"
    16  	"go.charczuk.com/sdk/db/dbutil"
    17  	"go.charczuk.com/sdk/uuid"
    18  	"go.charczuk.com/sdk/web"
    19  )
    20  
    21  var qf = new(dbutil.QueryFormatter).
    22  	WithModels(
    23  		Audit{},
    24  		Chirp{},
    25  		ChirpStats{},
    26  		ChirpStatus{},
    27  		ChirpLike{},
    28  		apputil.User{},
    29  		UserInfo{},
    30  		Feed{},
    31  		Graph{},
    32  		Notification{},
    33  	).WithTemplates(
    34  	templateUserColumns,
    35  	templateUserJoins,
    36  	templateChirpColumns,
    37  	templateChirpAuxJoins,
    38  	templateChirpBlockChecks,
    39  )
    40  
    41  var f = qf.MustFormat
    42  
    43  // queries live here in all their glory.
    44  var (
    45  	templateUserColumns = `{{ define "user_columns" }}
    46  	{{ columns_prefix_alias "User" "user_" "u" }}
    47  	, {{ columns_prefix_alias "UserInfo" "userinfo_" "ui" }}
    48  	, u.id as userstatus_user_id
    49  	, case when following.user_id is not null then 1 else 0 end as userstatus_is_following
    50  	, case when u.id = $1 then 1 else 0 end as userstatus_is_self
    51  {{ end }}`
    52  	templateUserJoins = `{{ define "user_joins" }}
    53  	{{ table "User" }} u
    54  	JOIN {{ table "UserInfo" }} ui on ui.user_id = u.id
    55  	LEFT JOIN {{ table "Graph" }} following ON following.user_id = $1 AND following.target_id = u.id AND type = 'follow'
    56  {{ end }}`
    57  
    58  	templateChirpColumns = `{{ define "chirp_columns" }}
    59  	{{ columns_prefix_alias "Chirp" "chirp_" "c" }}
    60  	, {{ columns_prefix_alias "ChirpStats" "chirp_stats_" "cs" }}
    61  	, {{ columns_prefix_alias "User" "chirp_user_" "u" }}
    62  	, {{ columns_prefix_alias "UserInfo" "chirp_userinfo_" "ui" }}
    63  
    64  	, case when cl.chirp_id is not null then 1 else 0 end as chirp_status_is_liked
    65  	, case when cr.id is not null then 1 else 0 end as chirp_status_is_quoted
    66  	
    67  	, {{ columns_prefix_alias "Chirp" "quoted_" "qc" }}
    68  	, {{ columns_prefix_alias "ChirpStats" "quoted_stats_" "qcs" }}
    69  	, {{ columns_prefix_alias "User" "quoted_user_" "qu" }}
    70  	, {{ columns_prefix_alias "UserInfo" "quoted_userinfo_" "qui" }}
    71  
    72  	, {{ columns_prefix_alias "Chirp" "reply_" "rc" }}
    73  	, {{ columns_prefix_alias "ChirpStats" "reply_stats_" "rcs" }}
    74  	, {{ columns_prefix_alias "User" "reply_user_" "ru" }}
    75  	, {{ columns_prefix_alias "UserInfo" "reply_userinfo_" "rui" }}
    76  {{ end }}`
    77  
    78  	templateChirpAuxJoins = `{{ define "chirp_aux_joins" }}
    79  	JOIN {{ table "ChirpStats" }} cs on cs.chirp_id = c.id
    80  	JOIN {{ table "User" }} u on c.user_id = u.id
    81  	JOIN {{ table "UserInfo" }} ui on c.user_id = ui.user_id
    82  
    83  	LEFT JOIN {{ table "ChirpLike" }} cl on cl.chirp_id = c.id AND cl.user_id = $1
    84  	LEFT JOIN {{ table "Chirp" }} cr on cr.id = c.quoted_id AND cl.user_id = $1
    85  
    86  	LEFT JOIN {{ table "Chirp" }} qc on qc.id = c.quoted_id
    87  	LEFT JOIN {{ table "ChirpStats" }} qcs on qcs.chirp_id = c.quoted_id
    88  	LEFT JOIN {{ table "User" }} qu on qu.id = qc.user_id
    89  	LEFT JOIN {{ table "UserInfo" }} qui on qui.user_id = qc.user_id
    90  	
    91  	LEFT JOIN {{ table "Chirp" }} rc on rc.id = c.reply_id
    92  	LEFT JOIN {{ table "ChirpStats" }} rcs on rcs.chirp_id = c.reply_id
    93  	LEFT JOIN {{ table "User" }} ru on ru.id = rc.user_id
    94  	LEFT JOIN {{ table "UserInfo" }} rui on rui.user_id = rc.user_id
    95  {{ end }}`
    96  
    97  	templateChirpBlockChecks = `{{ define "chirp_block_checks" }}
    98  	AND NOT EXISTS (SELECT 1 FROM {{ table "Graph" }} g WHERE g.user_id = $1 AND g.target_id = c.user_id AND type IN ('block', 'mute'))
    99  	AND NOT EXISTS (SELECT 1 FROM {{ table "Graph" }} g WHERE g.target_id = $1 AND g.user_id= c.user_id AND type = 'block')
   100  	AND (
   101  		c.quoted_id IS NULL 
   102  		OR (
   103  			c.quoted_id IS NOT NULL
   104  			AND NOT EXISTS (SELECT 1 FROM {{ table "Graph" }} g WHERE g.user_id = $1 AND g.target_id = qc.user_id AND type IN ('block', 'mute'))
   105  			AND NOT EXISTS (SELECT 1 FROM {{ table "Graph" }} g WHERE g.target_id = $1 AND g.user_id= qc.user_id AND type = 'block')
   106  		)
   107  	)
   108  	AND (
   109  		c.reply_id IS NULL 
   110  		OR (
   111  			c.reply_id IS NOT NULL
   112  			AND NOT EXISTS (SELECT 1 FROM {{ table "Graph" }} g WHERE g.user_id = $1 AND g.target_id = rc.user_id AND type IN ('block', 'mute'))
   113  			AND NOT EXISTS (SELECT 1 FROM {{ table "Graph" }} g WHERE g.target_id = $1 AND g.user_id= rc.user_id AND type = 'block')
   114  		)
   115  	)
   116  {{ end }}`
   117  
   118  	queryUser = f(`
   119  SELECT	
   120  	{{ template "user_columns" . }}
   121  FROM
   122  	{{ template "user_joins" . }}	
   123  WHERE
   124  	u.id = $2`, nil)
   125  
   126  	queryUserByUsername = f(`
   127  SELECT	
   128  	{{ template "user_columns" . }}
   129  FROM
   130  	{{ template "user_joins" . }}	
   131  WHERE
   132  	u.email = $2`, nil)
   133  
   134  	querySearchUsers = f(`
   135  SELECT	
   136  	{{ template "user_columns" . }}
   137  FROM
   138  	{{ template "user_joins" . }}	
   139  WHERE
   140  	u.email ilike $2
   141  	OR u.given_name ilike $2
   142  	OR u.family_name ilike $2
   143  	AND u.id <> $1
   144  	AND NOT EXISTS (SELECT 1 FROM {{ table "Graph" }} blocked WHERE blocked.target_id = $1 and blocked.user_id = u.id AND type = 'block')
   145  		`, nil)
   146  
   147  	queryChirp = f(`
   148  SELECT	
   149  	{{ template "chirp_columns" . }}
   150  FROM
   151  	{{ table "Chirp" }} c
   152  	{{ template "chirp_aux_joins" }}
   153  WHERE
   154  	c.id = $2
   155  	{{ template "chirp_block_checks" }}
   156  	`, nil)
   157  
   158  	queryChirpReplies = f(`
   159  SELECT	
   160  	{{ template "chirp_columns" . }}
   161  FROM
   162  	{{ table "Chirp" }} c
   163  	{{ template "chirp_aux_joins" }}
   164  WHERE
   165  	c.reply_id = $2
   166  	{{ template "chirp_block_checks" }}
   167  	`, nil)
   168  
   169  	queryFeedForUserID = f(`
   170  ; WITH feed_data as (
   171  	SELECT
   172  		{{ template "chirp_columns" . }}
   173  		, ROW_NUMBER() OVER (ORDER BY f.timestamp_utc desc) as feed_rank
   174  	FROM 
   175  		{{ table "Feed" }} f
   176  		JOIN {{ table "Chirp" }} c on c.id = f.chirp_id
   177  		{{ template "chirp_aux_joins" }}
   178  	WHERE 
   179  		f.user_id = $1
   180  		{{ template "chirp_block_checks" }}
   181  )
   182  SELECT
   183  	*
   184  FROM 
   185  	feed_data
   186  WHERE
   187  	$3::uuid IS NULL 
   188  	OR (
   189  		feed_rank > (SELECT feed_rank from feed_data WHERE chirp_id = $3 LIMIT 1)
   190  	)
   191  ORDER BY feed_rank ASC
   192  LIMIT $2
   193  `, nil)
   194  
   195  	queryProfilePosts = f(`
   196  ; WITH profile_data as (
   197  	SELECT 
   198  		{{ template "chirp_columns" . }}
   199  		, ROW_NUMBER() OVER (ORDER BY c.published_utc desc) as profile_rank
   200  	FROM 
   201  		{{ table "Chirp" }} c
   202  		{{ template "chirp_aux_joins" }}
   203  	WHERE
   204  		c.user_id = $2
   205  		AND c.published_utc is not null
   206  		AND c.reply_id is null
   207  		{{ template "chirp_block_checks" }}
   208  )
   209  SELECT
   210  	*
   211  FROM 
   212  	profile_data
   213  WHERE
   214  	$4::uuid IS NULL 
   215  	OR (
   216  		profile_rank > (SELECT profile_rank from profile_data WHERE chirp_id = $4 LIMIT 1)
   217  	)
   218  
   219  	ORDER BY profile_rank ASC
   220  LIMIT $3
   221  	`, nil)
   222  
   223  	queryProfileReplies = f(`
   224  ; WITH profile_data as (
   225  	SELECT 
   226  		{{ template "chirp_columns" . }}
   227  		, ROW_NUMBER() OVER (ORDER BY c.published_utc desc) as profile_rank
   228  	FROM 
   229  		{{ table "Chirp" }} c
   230  		{{ template "chirp_aux_joins" }}
   231  	WHERE
   232  		c.user_id = $2
   233  		AND c.published_utc is not null
   234  		AND c.reply_id is not null
   235  		{{ template "chirp_block_checks" }}
   236  )
   237  SELECT
   238  	*
   239  FROM 
   240  	profile_data
   241  WHERE
   242  	$4::uuid IS NULL 
   243  	OR (
   244  		profile_rank > (SELECT profile_rank from profile_data WHERE chirp_id = $4 LIMIT 1)
   245  	)
   246  ORDER BY profile_rank ASC
   247  LIMIT $3
   248  		`, nil)
   249  
   250  	queryProfileLikes = f(`
   251  ; WITH profile_data as (
   252  	SELECT 
   253  		{{ template "chirp_columns" . }}
   254  		, ROW_NUMBER() OVER (ORDER BY c.published_utc desc) as profile_rank
   255  	FROM 
   256  		{{ table "ChirpLike" }} clike
   257  		JOIN {{ table "Chirp" }} c ON clike.chirp_id = c.id
   258  		{{ template "chirp_aux_joins" }}
   259  	WHERE
   260  		clike.user_id = $2
   261  		{{ template "chirp_block_checks" }}
   262  )
   263  SELECT
   264  	*
   265  FROM 
   266  	profile_data
   267  WHERE
   268  	$4::uuid IS NULL 
   269  	OR (
   270  		profile_rank > (SELECT profile_rank from profile_data WHERE chirp_id = $4 LIMIT 1)
   271  	)
   272  ORDER BY profile_rank ASC
   273  LIMIT $3
   274  			`, nil)
   275  
   276  	queryNotifications = f(`
   277  ; WITH feed_data as (
   278  SELECT 
   279  	{{ columns_prefix_alias "Notification" "notification_" "n" }}
   280  	, {{ columns_prefix_alias "User" "user_" "u" }}
   281  	, {{ columns_prefix_alias "UserInfo" "userinfo_" "ui" }}
   282  	, {{ columns_prefix_alias "Chirp" "chirp_" "c" }}
   283  	, u.id as userstatus_user_id
   284  	, case when following.user_id is not null then 1 else 0 end as userstatus_is_following
   285  	, case when u.id = $1 then 1 else 0 end as userstatus_is_self
   286  	, ROW_NUMBER() OVER (ORDER BY n.created_utc desc) as feed_rank
   287  FROM 
   288  	{{ table "Notification" }} n
   289  	JOIN {{ table "User" }} u ON u.id = n.acting_user_id
   290  	JOIN {{ table "UserInfo" }} ui ON ui.user_id = n.acting_user_id
   291  	LEFT JOIN {{ table "Chirp" }} c ON c.id = n.chirp_id
   292  	LEFT JOIN {{ table "Graph" }} following ON following.user_id = $1 AND following.target_id = u.id AND following.type = 'follow'
   293  WHERE
   294  	n.user_id = $1
   295  )
   296  SELECT
   297  	*
   298  FROM
   299  	feed_data
   300  WHERE
   301  	$3::uuid IS NULL 
   302  	OR (
   303  		feed_rank > (SELECT feed_rank from feed_data WHERE notification_id = $3 LIMIT 1)
   304  	)
   305  ORDER BY
   306  	feed_rank ASC
   307  LIMIT $2`, nil)
   308  	queryNotificationsUnread = f(`SELECT count(id) FROM {{ table "Notification" }} WHERE read_utc is null AND user_id = $1`, nil)
   309  
   310  	execIncrementChirpReplies        = f(`UPDATE {{ table "ChirpStats" }} SET replies = replies + 1 WHERE chirp_id = $1`, nil)
   311  	execIncrementChirpRechirps       = f(`UPDATE {{ table "ChirpStats" }}  SET rechirps = rechirps + 1 WHERE chirp_id = $1`, nil)
   312  	execDeleteChirpFromFeeds         = f(`DELETE FROM {{ table "Feed" }} WHERE chirp_id = $1`, nil)
   313  	execDeleteChirpFromLikes         = f(`DELETE FROM {{ table "ChirpLike" }} WHERE chirp_id = $1`, nil)
   314  	execDeleteChirpFromNotifications = f(`DELETE FROM {{ table "Notification" }} WHERE chirp_id = $1`, nil)
   315  	execDecementChirpReplies         = f(`UPDATE {{ table "ChirpStats" }} SET replies = replies - 1 WHERE chirp_id = $1`, nil)
   316  	execDecementChirpRechirps        = f(`UPDATE {{ table "ChirpStats" }} SET rechirps = rechirps - 1 WHERE chirp_id = $1`, nil)
   317  	execIncrementChirpLikes          = f(`UPDATE {{ table "ChirpStats" }} SET likes = chirp_stats.likes + 1 WHERE chirp_id = $1`, nil)
   318  	execDecrementChirpLikes          = f(`UPDATE {{ table "ChirpStats" }} SET likes = chirp_stats.likes - 1 WHERE chirp_id = $1`, nil)
   319  	execAddUserChirpsToFeed          = f(`INSERT INTO {{ table "Feed" }} (user_id, timestamp_utc, chirp_id) 
   320  SELECT 
   321  	$1
   322  	, published_utc
   323  	, id 
   324  FROM 
   325  	{{ table "Chirp" }} c 
   326  WHERE 
   327  	c.user_id = $2
   328  	AND NOT EXISTS (SELECT 1 FROM {{ table "Feed" }} f WHERE f.user_id = $1 and f.chirp_id = c.id)
   329  `, nil)
   330  	execRemoveUserChirpsFromFeed = f(`DELETE FROM {{ table "Feed" }} WHERE user_id = $1 AND EXISTS (select 1 from chirp c where c.id = {{ table "Feed" }}.chirp_id AND c.user_id = $2)`, nil)
   331  	execMarkNotificationsRead    = f(`UPDATE {{ table "Notification" }} SET read_utc = $3 WHERE user_id = $1 and id = any ($2)`, nil)
   332  )
   333  
   334  // Manager is the datumsbase manager.
   335  type Manager struct {
   336  	dbutil.BaseManager
   337  }
   338  
   339  // EnsureUserInfoOnCreate creates the rest of the user components on login.
   340  func (m Manager) EnsureUserInfoOnCreate(ctx context.Context, user *apputil.User) error {
   341  	return m.Invoke(ctx).Create(&UserInfo{
   342  		UserID: user.ID,
   343  	})
   344  }
   345  
   346  // SessionState is an application specific session state.
   347  type SessionState struct {
   348  	apputil.SessionState
   349  	UnreadNotifications int
   350  	UserInfo            UserInfo
   351  }
   352  
   353  // EnsureUserInfoOnFetch fetches the rest of the user components.
   354  func (m Manager) EnsureUserInfoOnFetch(ctx context.Context, session *web.Session) error {
   355  	existingState, ok := session.State.(apputil.SessionState)
   356  	if !ok {
   357  		return fmt.Errorf("invalid session state; expected apputil.SessionState")
   358  	}
   359  	user := existingState.User
   360  	var userInfo UserInfo
   361  	if _, err := m.Invoke(ctx).Get(&userInfo, user.ID); err != nil {
   362  		return err
   363  	}
   364  	unreadNotifications, err := m.NotificationsUnread(ctx, user.ID)
   365  	if err != nil {
   366  		return err
   367  	}
   368  	session.State = SessionState{
   369  		SessionState:        existingState,
   370  		UnreadNotifications: unreadNotifications,
   371  		UserInfo:            userInfo,
   372  	}
   373  	return nil
   374  }
   375  
   376  // Audit creates a new audit log record.
   377  func (m Manager) Audit(ctx context.Context, userID uuid.UUID, verb, noun, subject string) error {
   378  	return m.Invoke(ctx).Create(&Audit{
   379  		TimestampUTC: time.Now().UTC(),
   380  		UserID:       userID,
   381  		Verb:         verb,
   382  		Noun:         noun,
   383  		Subject:      subject,
   384  	})
   385  }
   386  
   387  // FollowsForUserID gets the users that follow a given user.
   388  func (m Manager) FollowsForUserID(ctx context.Context, userID uuid.UUID) (output []uuid.UUID, err error) {
   389  	err = m.Invoke(ctx).Query(`select user_id from graph where target_id = $1 and type = $2`, userID, EdgeTypeFollow).OutMany(&output)
   390  	return
   391  }
   392  
   393  // FollowingForUserID gets the users that a given user follows.
   394  func (m Manager) FollowingForUserID(ctx context.Context, userID uuid.UUID) (output []uuid.UUID, err error) {
   395  	err = m.Invoke(ctx).Query(`select target_id from graph where user_id = $1 and type = $2`, userID, EdgeTypeFollow).OutMany(&output)
   396  	return
   397  }
   398  
   399  // BlocksForUserID gets the users a given user blocks.
   400  func (m Manager) BlocksForUserID(ctx context.Context, userID uuid.UUID) (output []uuid.UUID, err error) {
   401  	err = m.Invoke(ctx).Query(`select target_id from graph where user_id = $1 and type = $2`, userID, EdgeTypeBlock).OutMany(&output)
   402  	return
   403  }
   404  
   405  // BlocksForUserID gets the users a given user has muted.
   406  func (m Manager) MutesForUserID(ctx context.Context, userID uuid.UUID) (output []uuid.UUID, err error) {
   407  	err = m.Invoke(ctx).Query(`select target_id from graph where user_id = $1 and type = $2`, userID, EdgeTypeMute).OutMany(&output)
   408  	return
   409  }
   410  
   411  // User returns a user by ID.
   412  func (m Manager) User(ctx context.Context, selfUserID, userID uuid.UUID) (output UserFull, found bool, err error) {
   413  	found, err = m.Invoke(ctx).Query(queryUser, selfUserID, userID).Out(&output)
   414  	return
   415  }
   416  
   417  // UserByUsername returns a user by username.
   418  func (m Manager) UserByUsername(ctx context.Context, selfUserID uuid.UUID, username string) (output UserFull, found bool, err error) {
   419  	found, err = m.Invoke(ctx).Query(queryUserByUsername, selfUserID, username).Out(&output)
   420  	return
   421  }
   422  
   423  // SearchUsers returns a list of users that match a search query.
   424  func (m Manager) SearchUsers(ctx context.Context, userID uuid.UUID, query string) (output []UserFull, err error) {
   425  	err = m.Invoke(ctx).Query(querySearchUsers, userID, "%"+query+"%").OutMany(&output)
   426  	return
   427  }
   428  
   429  // Chirp gets a chirp by ID (and infers status based on the provided userID).
   430  func (m Manager) Chirp(ctx context.Context, userID, id uuid.UUID) (c ChirpFull, found bool, err error) {
   431  	found, err = m.Invoke(ctx).Query(queryChirp, userID, id).Out(&c)
   432  	return
   433  }
   434  
   435  // FeedForUserID returns the feed for a given user, optionally after a given chirpID.
   436  func (m Manager) FeedForUserID(ctx context.Context, userID uuid.UUID, take int, afterChirpID *uuid.UUID) (output []ChirpFull, err error) {
   437  	err = m.Invoke(ctx).Query(queryFeedForUserID, userID, take, afterChirpID).OutMany(&output)
   438  	return
   439  }
   440  
   441  // ProfilePosts returns the posts (non-reply, non-likes) for a given user, optionally after a given chirpID.
   442  func (m Manager) ProfilePosts(ctx context.Context, selfUserID, userID uuid.UUID, take int, afterChirpID *uuid.UUID) (output []ChirpFull, err error) {
   443  	err = m.Invoke(ctx).Query(queryProfilePosts, selfUserID, userID, take, afterChirpID).OutMany(&output)
   444  	return
   445  }
   446  
   447  // ProfileReplies returns the posts (non-reply, non-likes) for a given user, optionally after a given chirpID.
   448  func (m Manager) ProfileReplies(ctx context.Context, selfUserID, userID uuid.UUID, take int, afterChirpID *uuid.UUID) (output []ChirpFull, err error) {
   449  	err = m.Invoke(ctx).Query(queryProfileReplies, selfUserID, userID, take, afterChirpID).OutMany(&output)
   450  	return
   451  }
   452  
   453  // ProfileLikes returns the liked chirps for a given user.
   454  func (m Manager) ProfileLikes(ctx context.Context, selfUserID, targetUserID uuid.UUID, take int, afterChirpID *uuid.UUID) (output []ChirpFull, err error) {
   455  	err = m.Invoke(ctx).Query(queryProfileLikes, selfUserID, targetUserID, take, afterChirpID).OutMany(&output)
   456  	return
   457  }
   458  
   459  // Notifications yields the last N notifications for a user
   460  func (m Manager) Notifications(ctx context.Context, userID uuid.UUID, take int, afterNotificationID *uuid.UUID) (output []NotificationFull, err error) {
   461  	err = m.Invoke(ctx).Query(queryNotifications, userID, take, afterNotificationID).OutMany(&output)
   462  	return
   463  }
   464  
   465  // NotificationsUnread returns the unread notification count.
   466  func (m Manager) NotificationsUnread(ctx context.Context, userID uuid.UUID) (output int, err error) {
   467  	_, err = m.Invoke(ctx).Query(queryNotificationsUnread, userID).Scan(&output)
   468  	return
   469  }
   470  
   471  // MarkNotificationsRead marks a list of notifications as read.
   472  func (m Manager) MarkNotificationsRead(ctx context.Context, userID uuid.UUID, notificationIDs []uuid.UUID) error {
   473  	_, err := m.Invoke(ctx).Exec(execMarkNotificationsRead, userID, notificationIDs, time.Now().UTC())
   474  	return err
   475  }
   476  
   477  // ChirpReplies returns the replies for a given chirp id.
   478  func (m Manager) ChirpReplies(ctx context.Context, selfUserID, chirpID uuid.UUID) (output []ChirpFull, err error) {
   479  	err = m.Invoke(ctx).Query(queryChirpReplies, selfUserID, chirpID).OutMany(&output)
   480  	return
   481  }
   482  
   483  // CreateChirp creates the chirp staging it for publishing.
   484  func (m Manager) CreateChirp(ctx context.Context, c *Chirp) (err error) {
   485  	c.CreatedUTC = time.Now().UTC()
   486  	c.PublishedUTC = nil
   487  	if err = m.Invoke(ctx).Create(c); err != nil {
   488  		return
   489  	}
   490  	if err = m.Invoke(ctx).Create(&ChirpStats{
   491  		UserID:  c.UserID,
   492  		ChirpID: c.ID,
   493  	}); err != nil {
   494  		return
   495  	}
   496  	return
   497  }
   498  
   499  // PublishChirp publishes the chirp, adding attachments and forwarding to the feed table.
   500  func (m Manager) PublishChirp(ctx context.Context, c *Chirp) (err error) {
   501  	t := time.Now().UTC()
   502  	c.PublishedUTC = &t
   503  	if _, err = m.Invoke(ctx).Update(c); err != nil {
   504  		return
   505  	}
   506  	if c.ReplyID != nil && !c.ReplyID.IsZero() {
   507  		if _, err = m.Invoke(ctx).Exec(execIncrementChirpReplies, c.ReplyID); err != nil {
   508  			return
   509  		}
   510  	}
   511  	if c.QuotedID != nil && !c.QuotedID.IsZero() {
   512  		if _, err = m.Invoke(ctx).Exec(execIncrementChirpRechirps, c.QuotedID); err != nil {
   513  			return
   514  		}
   515  	}
   516  
   517  	// Publish for self
   518  	if err = m.Invoke(ctx).Create(Feed{
   519  		UserID:       c.UserID,
   520  		ChirpID:      c.ID,
   521  		TimestampUTC: t,
   522  	}); err != nil {
   523  		return
   524  	}
   525  
   526  	// Publish for those that are following
   527  	var follows []uuid.UUID
   528  	follows, err = m.FollowsForUserID(ctx, c.UserID)
   529  	if err != nil {
   530  		return
   531  	}
   532  	for _, f := range follows {
   533  		if err = m.Invoke(ctx).Create(Feed{
   534  			UserID:       f,
   535  			ChirpID:      c.ID,
   536  			TimestampUTC: t,
   537  		}); err != nil {
   538  			return
   539  		}
   540  	}
   541  	return
   542  }
   543  
   544  // DeleteChirp deletes a chirp and removes it from any referenced table(s).
   545  func (m Manager) DeleteChirp(ctx context.Context, c Chirp) (deleted bool, err error) {
   546  	_, err = m.Invoke(ctx).Exec(execDeleteChirpFromFeeds, c.ID)
   547  	if err != nil {
   548  		return
   549  	}
   550  	_, err = m.Invoke(ctx).Exec(execDeleteChirpFromLikes, c.ID)
   551  	if err != nil {
   552  		return
   553  	}
   554  	_, err = m.Invoke(ctx).Exec(execDeleteChirpFromNotifications, c.ID)
   555  	if err != nil {
   556  		return
   557  	}
   558  	_, err = m.Invoke(ctx).Delete(ChirpStats{ChirpID: c.ID})
   559  	if err != nil {
   560  		return
   561  	}
   562  	deleted, err = m.Invoke(ctx).Delete(c)
   563  	if err != nil {
   564  		return
   565  	}
   566  	if deleted {
   567  		if c.ReplyID != nil && !c.ReplyID.IsZero() {
   568  			if _, err = m.Invoke(ctx).Exec(execDecementChirpReplies, c.ReplyID); err != nil {
   569  				return
   570  			}
   571  		}
   572  		if c.QuotedID != nil && !c.QuotedID.IsZero() {
   573  			if _, err = m.Invoke(ctx).Exec(execDecementChirpRechirps, c.QuotedID); err != nil {
   574  				return
   575  			}
   576  		}
   577  	}
   578  	return
   579  }
   580  
   581  // LikeChirp applies a like to a chirp.
   582  func (m Manager) LikeChirp(ctx context.Context, userID uuid.UUID, chirp Chirp) error {
   583  	if _, err := m.Invoke(ctx).Exec(execIncrementChirpLikes, chirp.ID); err != nil {
   584  		return err
   585  	}
   586  	if err := m.Invoke(ctx).Create(&ChirpLike{
   587  		ChirpID:      chirp.ID,
   588  		UserID:       userID,
   589  		TimestampUTC: time.Now().UTC(),
   590  	}); err != nil {
   591  		return err
   592  	}
   593  	if !chirp.UserID.Equal(userID) {
   594  		if err := m.Invoke(ctx).Create(&Notification{
   595  			CreatedUTC:   time.Now().UTC(),
   596  			ActingUserID: userID,
   597  			UserID:       chirp.UserID,
   598  			ChirpID:      &chirp.ID,
   599  			Type:         NotificationTypeLike,
   600  		}); err != nil {
   601  			return err
   602  		}
   603  	}
   604  	return nil
   605  }
   606  
   607  // UnlikeChirp undoes a like to a chirp.
   608  func (m Manager) UnlikeChirp(ctx context.Context, userID, chirpID uuid.UUID) error {
   609  	deleted, err := m.Invoke(ctx).Delete(&ChirpLike{
   610  		UserID:  userID,
   611  		ChirpID: chirpID,
   612  	})
   613  	if err != nil {
   614  		return err
   615  	}
   616  	if deleted {
   617  		if _, err := m.Invoke(ctx).Exec(execDecrementChirpLikes, chirpID); err != nil {
   618  			return err
   619  		}
   620  	}
   621  	return nil
   622  }
   623  
   624  // Follow adds a graph record that a given user follows a target user as well
   625  // as adds that target user's chirps to the given user's feed.
   626  func (m Manager) Follow(ctx context.Context, userID, targetUserID uuid.UUID) error {
   627  	if err := m.Invoke(ctx).Create(&Graph{
   628  		TimestampUTC: time.Now().UTC(),
   629  		UserID:       userID,
   630  		TargetID:     targetUserID,
   631  		Type:         EdgeTypeFollow,
   632  	}); err != nil {
   633  		return err
   634  	}
   635  	if _, err := m.Invoke(ctx).Exec(execAddUserChirpsToFeed, userID /* who's feed we're adding to */, targetUserID /* add this users chirps to my feed */); err != nil {
   636  		return err
   637  	}
   638  	if err := m.Invoke(ctx).Create(&Notification{
   639  		CreatedUTC:   time.Now().UTC(),
   640  		UserID:       targetUserID,
   641  		ActingUserID: userID,
   642  		Type:         NotificationTypeFollow,
   643  	}); err != nil {
   644  		return err
   645  	}
   646  	return nil
   647  }
   648  
   649  // Unfollow is the reverse of follow and removes a target user's chirps from a given
   650  // user's feed and removes the graph record that the given user follows the target user.
   651  func (m Manager) Unfollow(ctx context.Context, userID, targetUserID uuid.UUID) error {
   652  	if _, err := m.Invoke(ctx).Delete(&Graph{
   653  		UserID:   userID,
   654  		TargetID: targetUserID,
   655  		Type:     EdgeTypeFollow,
   656  	}); err != nil {
   657  		return err
   658  	}
   659  	if _, err := m.Invoke(ctx).Exec(execRemoveUserChirpsFromFeed, userID /* who's feed we're removing from*/, targetUserID /*remove this users chirps from my feed */); err != nil {
   660  		return err
   661  	}
   662  	return nil
   663  }
   664  
   665  // Mute adds a graph record to indicate we should not show a target user's chirps
   666  // in a given user's feed but we should not block the target user from following the
   667  // given user or seeing their chirps.
   668  func (m Manager) Mute(ctx context.Context, userID, targetUserID uuid.UUID) error {
   669  	if err := m.Invoke(ctx).Create(&Graph{
   670  		TimestampUTC: time.Now().UTC(),
   671  		UserID:       userID,
   672  		TargetID:     targetUserID,
   673  		Type:         EdgeTypeMute,
   674  	}); err != nil {
   675  		return err
   676  	}
   677  	return nil
   678  }
   679  
   680  // Unmute is the reverse of mute and allows a target user's chirps to appear
   681  // in a given user's feed.
   682  func (m Manager) Unmute(ctx context.Context, userID, targetUserID uuid.UUID) error {
   683  	if _, err := m.Invoke(ctx).Delete(&Graph{
   684  		UserID:   userID,
   685  		TargetID: targetUserID,
   686  		Type:     EdgeTypeMute,
   687  	}); err != nil {
   688  		return err
   689  	}
   690  	return nil
   691  }
   692  
   693  // Block blocks a user.
   694  func (m Manager) Block(ctx context.Context, userID, targetUserID uuid.UUID) error {
   695  	if err := m.Invoke(ctx).Create(&Graph{
   696  		TimestampUTC: time.Now().UTC(),
   697  		UserID:       userID,
   698  		TargetID:     targetUserID,
   699  		Type:         EdgeTypeBlock,
   700  	}); err != nil {
   701  		return err
   702  	}
   703  	return nil
   704  }
   705  
   706  // Unblock unblocks a user.
   707  func (m Manager) Unblock(ctx context.Context, userID, targetUserID uuid.UUID) error {
   708  	if _, err := m.Invoke(ctx).Delete(&Graph{
   709  		UserID:   userID,
   710  		TargetID: targetUserID,
   711  		Type:     EdgeTypeBlock,
   712  	}); err != nil {
   713  		return err
   714  	}
   715  	return nil
   716  }