~netlandish/links-dev

links: Adding audit logs for various data modification functions. v1 APPLIED

Peter Sanchez: 1
 Adding audit logs for various data modification functions.

 5 files changed, 554 insertions(+), 46 deletions(-)
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.code.netlandish.com/~netlandish/links-dev/patches/98/mbox | git am -3
Learn more about email & git

[PATCH links] Adding audit logs for various data modification functions. Export this patch

---
Still pending some handlers to display audit logs but I wanted to ensure
we get record keeping in place asap as more and more users register.

 accounts/userfetch.go         |  65 +++++
 api/graph/schema.graphqls     |   6 +-
 api/graph/schema.resolvers.go | 459 +++++++++++++++++++++++++++++++---
 models/audit_log.go           |  65 +++++
 models/models.go              |   5 -
 5 files changed, 554 insertions(+), 46 deletions(-)
 create mode 100644 models/audit_log.go

diff --git a/accounts/userfetch.go b/accounts/userfetch.go
index 6b8bba4..b509086 100644
--- a/accounts/userfetch.go
+++ b/accounts/userfetch.go
@@ -143,7 +143,20 @@ func (u *UserFetch) ProcessSuccessfulLogout(c echo.Context) error {

// ProcessSuccessfulPasswordChange handle tasks after user is logged in
func (u *UserFetch) ProcessSuccessfulPasswordChange(c echo.Context) error {
	gctx := c.(*server.Context)
	lt := localizer.GetSessionLocalizer(c)
	err := models.RecordAuditLog(
		c.Request().Context(),
		int(gctx.User.GetID()),
		c.RealIP(),
		models.LOG_ACCT_PASSWORD_CHANGE,
		"Successfully changed password.",
		nil,
	)
	if err != nil {
		return err
	}

	messages.Success(c, lt.Translate("You've successfully updated your password."))
	return nil
}
@@ -151,6 +164,19 @@ func (u *UserFetch) ProcessSuccessfulPasswordChange(c echo.Context) error {
// ProcessSuccessfulPasswordReset handle tasks after user is logged in
func (u *UserFetch) ProcessSuccessfulPasswordReset(c echo.Context) error {
	lt := localizer.GetSessionLocalizer(c)
	user := c.Get("user").(*models.User)
	err := models.RecordAuditLog(
		c.Request().Context(),
		int(user.ID),
		c.RealIP(),
		models.LOG_ACCT_PASSWORD_RESET,
		"Successfully reset password.",
		nil,
	)
	if err != nil {
		return err
	}

	messages.Success(c, lt.Translate("You've successfully updated your password."))
	return nil
}
@@ -158,6 +184,19 @@ func (u *UserFetch) ProcessSuccessfulPasswordReset(c echo.Context) error {
// ProcessSuccessfulEmailUpdate handle tasks after user is logged in
func (u *UserFetch) ProcessSuccessfulEmailUpdate(c echo.Context) error {
	lt := localizer.GetSessionLocalizer(c)
	user := c.Get("user").(*models.User)
	err := models.RecordAuditLog(
		c.Request().Context(),
		int(user.ID),
		c.RealIP(),
		models.LOG_ACCT_EMAIL_UPDATE,
		"Successfully updated account email.",
		nil,
	)
	if err != nil {
		return err
	}

	messages.Success(c, lt.Translate("You've successfully updated your email address."))
	return nil
}
@@ -180,6 +219,19 @@ func (u *UserFetch) ProcessSuccessfulEmailConfirmation(c echo.Context) error {
	if err != nil {
		gctx.Server.Logger().Printf("Error queueing SendySubscribeTask: %v", err)
	}

	err = models.RecordAuditLog(
		c.Request().Context(),
		int(user.ID),
		c.RealIP(),
		models.LOG_ACCT_EMAIL_CONF,
		"Successfully confirmed account email.",
		nil,
	)
	if err != nil {
		return err
	}

	messages.Success(c, lt.Translate("You've successfully confirmed your email address."))
	return nil
}
@@ -234,6 +286,19 @@ func (u *UserFetch) ProcessSuccessfulLogin(c echo.Context, user gobwebs.User) er
	links.SetUserSession(c, lUser)

	lt := localizer.GetSessionLocalizer(c)

	err := models.RecordAuditLog(
		c.Request().Context(),
		int(lUser.ID),
		c.RealIP(),
		models.LOG_ACCT_LOGIN,
		"Successful account login.",
		nil,
	)
	if err != nil {
		return err
	}

	messages.Success(c, lt.Translate("Successful login."))
	return nil
}
diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls
index 4612125..79f72eb 100644
--- a/api/graph/schema.graphqls
+++ b/api/graph/schema.graphqls
@@ -814,6 +814,9 @@ type Mutation {
    "Create a new organization"
    addOrganization(input: OrganizationInput!): Organization! @access(scope: ORGS, kind: RW)

    "Update organization details"
    updateOrganization(input: UpdateOrganizationInput): Organization! @access(scope: ORGS, kind: RW)

    "Add/Edit/Delete organization link. Also used edit/delete notes"
    addLink(input: LinkInput): OrgLink! @access(scope: LINKS, kind: RW)
    updateLink(input: UpdateLinkInput): OrgLink! @access(scope: LINKS, kind: RW)
@@ -834,9 +837,6 @@ type Mutation {
    "Update user profile"
    updateProfile(input: ProfileInput): User! @access(scope: PROFILE, kind: RW)

    "Update organization details"
    updateOrganization(input: UpdateOrganizationInput): Organization! @access(scope: ORGS, kind: RW)

    "Manage custom domains for an organization"
    addDomain(input: DomainInput!): Domain! @access(scope: DOMAINS, kind: RW)
    deleteDomain(id: Int!): DeletePayload! @access(scope: DOMAINS, kind: RW)
diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index d82d48b..bd8d6ed 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -39,7 +39,6 @@ import (
	"golang.org/x/image/draw"
	"golang.org/x/net/idna"
	"netlandish.com/x/gobwebs"
	auditlog "netlandish.com/x/gobwebs-auditlog"
	oauth2 "netlandish.com/x/gobwebs-oauth2"
	gaccounts "netlandish.com/x/gobwebs/accounts"
	gcore "netlandish.com/x/gobwebs/core"
@@ -89,6 +88,7 @@ func (r *mutationResolver) AddOrganization(ctx context.Context, input model.Orga
		return nil, valid.ErrAuthorization
	}
	user := tokenUser.User.(*models.User)
	c := server.EchoForContext(ctx)
	lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), user)
	lt := localizer.GetLocalizer(lang)

@@ -182,6 +182,20 @@ func (r *mutationResolver) AddOrganization(ctx context.Context, input model.Orga
		return nil, err
	}

	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		models.LOG_ORG_ADDED,
		fmt.Sprintf("Added organization '%s' (%d)", org.Slug, org.ID),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	return org, nil
}

@@ -352,6 +366,21 @@ func (r *mutationResolver) AddLink(ctx context.Context, input *model.LinkInput)
		}
	}

	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	err = models.RecordAuditLog(
		ctx,
		userID,
		c.RealIP(),
		models.LOG_BOOKMARK_ADDED,
		fmt.Sprintf("Added bookmark '%s'", OrgLink.Hash),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	return OrgLink, nil
}

@@ -545,6 +574,28 @@ func (r *mutationResolver) UpdateLink(ctx context.Context, input *model.UpdateLi
			return nil, err
		}
	}

	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	ltype := models.LOG_BOOKMARK_UPDATED
	ldet := "bookmark"
	if orgLink.Type == models.NoteType {
		ltype = models.LOG_NOTE_UPDATED
		ldet = "note"
	}
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		ltype,
		fmt.Sprintf("Updated %s '%s'", ldet, orgLink.Hash),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	return orgLink, nil
}

@@ -580,28 +631,28 @@ func (r *mutationResolver) DeleteLink(ctx context.Context, hash string) (*model.
	}
	link := orgLinks[0]

	// If the user is not the link creator, verify org write permissions
	if link.UserID != int(user.ID) {
		opts = &database.FilterOptions{
			Filter: sq.And{
				sq.Expr("o.id = ?", link.OrgID),
				sq.Expr("o.is_active = true"),
			},
			Limit: 1,
		}
	opts = &database.FilterOptions{
		Filter: sq.And{
			sq.Expr("o.id = ?", link.OrgID),
			sq.Expr("o.is_active = true"),
		},
		Limit: 1,
	}

		orgs, err := models.GetOrganizations(ctx, opts)
		if err != nil {
			return nil, err
		}
	orgs, err := models.GetOrganizations(ctx, opts)
	if err != nil {
		return nil, err
	}

		if len(orgs) == 0 {
			validator.Error("%s", lt.Translate("This user is not allowed to perform this action")).
				WithCode(valid.ErrNotFoundCode)
			return nil, nil
		}
	if len(orgs) == 0 {
		validator.Error("%s", lt.Translate("This user is not allowed to perform this action")).
			WithCode(valid.ErrNotFoundCode)
		return nil, nil
	}
	org := orgs[0]

		org := orgs[0]
	// If the user is not the link creator, verify org write permissions
	if link.UserID != int(user.ID) {
		if !org.CanWrite(ctx, user) {
			validator.Error("%s", lt.Translate("This user is not allowed to perform this action")).
				WithCode(valid.ErrNotFoundCode)
@@ -628,6 +679,28 @@ func (r *mutationResolver) DeleteLink(ctx context.Context, hash string) (*model.
			return nil, err
		}
	}

	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	ltype := models.LOG_BOOKMARK_DELETED
	ldet := "bookmark"
	if link.Type == models.NoteType {
		ltype = models.LOG_NOTE_DELETED
		ldet = "note"
	}
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		ltype,
		fmt.Sprintf("Deleted %s '%s'", ldet, link.Hash),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	deletePayload.Success = true
	deletePayload.ObjectID = deletedID
	return deletePayload, nil
@@ -929,6 +1002,22 @@ func (r *mutationResolver) AddMember(ctx context.Context, input *model.MemberInp
	if err != nil {
		return nil, err
	}

	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["user_id"] = user.ID
	err = models.RecordAuditLog(
		ctx,
		int(currentUser.ID),
		c.RealIP(),
		models.LOG_MEMBER_ADDED,
		fmt.Sprintf("Added member %s", user.Email),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	addMemberPayload.Success = true
	return addMemberPayload, nil
}
@@ -977,8 +1066,9 @@ func (r *mutationResolver) DeleteMember(ctx context.Context, orgSlug string, ema
		return nil, nil
	}

	email = strings.ToLower(email)
	opts := &database.FilterOptions{
		Filter: sq.Eq{"u.email": strings.ToLower(email)},
		Filter: sq.Eq{"u.email": email},
		Limit:  1,
	}

@@ -1015,6 +1105,22 @@ func (r *mutationResolver) DeleteMember(ctx context.Context, orgSlug string, ema
				return nil, err
			}
		}

		mdata := make(map[string]any)
		mdata["org_id"] = org.ID
		mdata["user_id"] = duser.ID
		err = models.RecordAuditLog(
			ctx,
			int(user.ID),
			c.RealIP(),
			models.LOG_MEMBER_REMOVED,
			fmt.Sprintf("Removed member %s", email),
			mdata,
		)
		if err != nil {
			return nil, err
		}

		addMemberPayload.Success = true
		addMemberPayload.Message = lt.Translate("The member was removed successfully")
	}
@@ -1126,6 +1232,22 @@ func (r *mutationResolver) ConfirmMember(ctx context.Context, key string) (*mode
	if err != nil {
		return nil, err
	}

	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		models.LOG_MEMBER_CONFIRMED,
		fmt.Sprintf("Confirmed member %s", user.Email),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	addMemberPayload := &model.AddMemberPayload{
		Success: true,
		Message: lt.Translate("The user was added successfully"),
@@ -1585,6 +1707,21 @@ func (r *mutationResolver) UpdateProfile(ctx context.Context, input *model.Profi
		}
	}

	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = personalOrg.ID
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		models.LOG_PROFILE_UPDATED,
		fmt.Sprintf("Updated profile"),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	return user, nil
}

@@ -1764,6 +1901,21 @@ func (r *mutationResolver) UpdateOrganization(ctx context.Context, input *model.
		return nil, err
	}

	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		models.LOG_ORG_UPDATED,
		fmt.Sprintf("Updated organization '%s' (%d)", org.Slug, org.ID),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	return org, nil
}

@@ -1901,6 +2053,23 @@ func (r *mutationResolver) AddDomain(ctx context.Context, input model.DomainInpu
			return nil, err
		}
	}

	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["domain_id"] = domain.ID
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		models.LOG_DOMAIN_ADDED,
		fmt.Sprintf("Added domain '%s'", domain.LookupName),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	return domain, nil
}

@@ -1969,6 +2138,22 @@ func (r *mutationResolver) DeleteDomain(ctx context.Context, id int) (*model.Del
		return nil, err
	}

	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = domain.OrgID
	mdata["domain_id"] = domain.ID
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		models.LOG_DOMAIN_DELETED,
		fmt.Sprintf("Deleted domain '%s'", domain.LookupName),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	deletePayload.Success = true
	deletePayload.ObjectID = fmt.Sprint(id)
	return deletePayload, nil
@@ -2170,14 +2355,16 @@ func (r *mutationResolver) AddLinkShort(ctx context.Context, input *model.LinkSh
		}
	}

	alog := auditlog.New(
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		models.LOG_SHORT_ADDED,
		fmt.Sprintf("Added short link '%s'", linkShort.ShortCode),
		fmt.Sprintf("Added short link '%s' (%d)", linkShort.ShortCode, linkShort.ID),
		mdata,
	)
	alog.AddMetadata("org_id", org.ID)
	err = alog.Store(ctx)
	if err != nil {
		return nil, err
	}
@@ -2192,6 +2379,7 @@ func (r *mutationResolver) UpdateLinkShort(ctx context.Context, input *model.Upd
		return nil, valid.ErrAuthorization
	}
	user := tokenUser.User.(*models.User)
	c := server.EchoForContext(ctx)
	lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), user)
	lt := localizer.GetLocalizer(lang)

@@ -2362,6 +2550,20 @@ func (r *mutationResolver) UpdateLinkShort(ctx context.Context, input *model.Upd
		}
	}

	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		models.LOG_SHORT_UPDATED,
		fmt.Sprintf("Updated short link '%s' (%d)", linkShort.ShortCode, linkShort.ID),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	return linkShort, nil
}

@@ -2372,6 +2574,7 @@ func (r *mutationResolver) DeleteLinkShort(ctx context.Context, id int) (*model.
		return nil, valid.ErrAuthorization
	}
	user := tokenUser.User.(*models.User)
	c := server.EchoForContext(ctx)
	lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), user)
	lt := localizer.GetLocalizer(lang)
	deletePayload := &model.DeletePayload{
@@ -2413,6 +2616,21 @@ func (r *mutationResolver) DeleteLinkShort(ctx context.Context, id int) (*model.
	if err != nil {
		return nil, err
	}

	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		models.LOG_SHORT_DELETED,
		fmt.Sprintf("Deleted short link '%s' (%d)", link.ShortCode, link.ID),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	deletePayload.Success = true
	deletePayload.ObjectID = deletedID
	return deletePayload, nil
@@ -2714,6 +2932,22 @@ func (r *mutationResolver) AddListing(ctx context.Context, input *model.AddListi
		}
	}

	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["list_id"] = listing.ID
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		models.LOG_LIST_ADDED,
		fmt.Sprintf("Added link listing '%s' (%d)", listing.Slug, listing.ID),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	return listing, nil
}

@@ -2799,6 +3033,24 @@ func (r *mutationResolver) AddListingLink(ctx context.Context, input *model.AddL
	if err != nil {
		return nil, err
	}

	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["list_id"] = listing.ID
	mdata["list_link_id"] = listingLink.ID
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		models.LOG_LIST_LINK_ADDED,
		fmt.Sprintf("Added listing entry '%s' (%d)", listingLink.Title, listingLink.ID),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	return listingLink, nil
}

@@ -3126,6 +3378,23 @@ func (r *mutationResolver) UpdateListing(ctx context.Context, input *model.Updat
			return nil, err
		}
	}

	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["list_id"] = listing.ID
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		models.LOG_LIST_UPDATED,
		fmt.Sprintf("Updated link listing '%s' (%d)", listing.Slug, listing.ID),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	return listing, nil
}

@@ -3199,6 +3468,24 @@ func (r *mutationResolver) UpdateListingLink(ctx context.Context, input *model.U
	if err != nil {
		return nil, err
	}

	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["list_id"] = listingLink.ListingID
	mdata["list_link_id"] = listingLink.ID
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		models.LOG_LIST_LINK_UPDATED,
		fmt.Sprintf("Updated listing entry '%s' (%d)", listingLink.Title, listingLink.ID),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	return listingLink, nil
}

@@ -3251,6 +3538,23 @@ func (r *mutationResolver) DeleteListing(ctx context.Context, id int) (*model.De
	if err != nil {
		return nil, err
	}

	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["list_id"] = listing.ID
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		models.LOG_LIST_DELETED,
		fmt.Sprintf("Deleted link listing '%s' (%d)", listing.Slug, listing.ID),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	deletePayload.Success = true
	deletePayload.ObjectID = deletedID
	return deletePayload, nil
@@ -3305,6 +3609,24 @@ func (r *mutationResolver) DeleteListingLink(ctx context.Context, id int) (*mode
	if err != nil {
		return nil, err
	}

	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["list_id"] = listingLink.ListingID
	mdata["list_link_id"] = listingLink.ID
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		models.LOG_LIST_LINK_DELETED,
		fmt.Sprintf("Updated listing entry '%s' (%d)", listingLink.Title, listingLink.ID),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	deletePayload.Success = true
	deletePayload.ObjectID = deletedID
	return deletePayload, nil
@@ -3580,6 +3902,23 @@ func (r *mutationResolver) AddQRCode(ctx context.Context, input model.AddQRCodeI
			return nil, err
		}
	}

	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["qrcode_id"] = qr.ID
	mdata["qrcode_type"] = qr.CodeType
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		models.LOG_QRCODE_ADDED,
		fmt.Sprintf("Added QR code '%s', type: %s (%d)", qr.Title, qr.CodeType, qr.ID),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	return qr, nil
}

@@ -3632,6 +3971,24 @@ func (r *mutationResolver) DeleteQRCode(ctx context.Context, id int) (*model.Del
	if err != nil {
		return nil, err
	}

	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["qrcode_id"] = qrCode.ID
	mdata["qrcode_type"] = qrCode.CodeType
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		models.LOG_QRCODE_DELETED,
		fmt.Sprintf("Deleted QR code '%s', type: %s (%d)", qrCode.Title, qrCode.CodeType, qrCode.ID),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	deletePayload.Success = true
	deletePayload.ObjectID = deletedID
	return deletePayload, nil
@@ -3677,21 +4034,31 @@ func (r *mutationResolver) Follow(ctx context.Context, orgSlug string) (*model.F
		return nil, err
	}

	if len(follows) > 0 {
		validator.Error("%s", lt.Translate("This user already follows this org")).
			WithField("orgSlug").
			WithCode(valid.ErrValidationCode)
		return nil, nil
	}
	if len(follows) == 0 {
		follow := &models.Follower{
			UserID: int(user.ID),
			OrgID:  org.ID,
		}

	follow := &models.Follower{
		UserID: int(user.ID),
		OrgID:  org.ID,
	}
		err = follow.Store(ctx)
		if err != nil {
			return nil, err
		}

	err = follow.Store(ctx)
	if err != nil {
		return nil, err
		c := server.EchoForContext(ctx)
		mdata := make(map[string]any)
		mdata["org_id"] = org.ID
		err = models.RecordAuditLog(
			ctx,
			int(user.ID),
			c.RealIP(),
			models.LOG_ORG_FOLLOW,
			fmt.Sprintf("Followed organization '%s' (%d)", org.Slug, org.ID),
			mdata,
		)
		if err != nil {
			return nil, err
		}
	}

	payload := &model.FollowPayload{
@@ -3754,6 +4121,22 @@ func (r *mutationResolver) Unfollow(ctx context.Context, orgSlug string) (*model
	if err != nil {
		return nil, err
	}

	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		models.LOG_ORG_UNFOLLOW,
		fmt.Sprintf("Unfollowed organization '%s' (%d)", org.Slug, org.ID),
		mdata,
	)
	if err != nil {
		return nil, err
	}

	payload := &model.FollowPayload{
		Success: true,
		Message: lt.Translate("User unfollows %s", orgSlug),
diff --git a/models/audit_log.go b/models/audit_log.go
new file mode 100644
index 0000000..5e5f268
--- /dev/null
+++ b/models/audit_log.go
@@ -0,0 +1,65 @@
package models

import (
	"context"

	auditlog "netlandish.com/x/gobwebs-auditlog"
)

// Event types for audit log entries
const (
	LOG_ACCT_LOGIN           = "account_login"
	LOG_ACCT_EMAIL_UPDATE    = "account_email_update"
	LOG_ACCT_EMAIL_CONF      = "account_email_confirmation"
	LOG_ACCT_PASSWORD_CHANGE = "account_password_change"
	LOG_ACCT_PASSWORD_RESET  = "account_password_reset"

	LOG_PROFILE_UPDATED = "profile_updated"

	LOG_ORG_ADDED   = "organization_added"
	LOG_ORG_UPDATED = "organization_updated"

	LOG_BOOKMARK_ADDED   = "bookmark_added"
	LOG_BOOKMARK_UPDATED = "bookmark_added"
	LOG_BOOKMARK_DELETED = "bookmark_added"

	LOG_NOTE_ADDED   = "note_added"
	LOG_NOTE_UPDATED = "note_added"
	LOG_NOTE_DELETED = "note_added"

	LOG_SHORT_ADDED   = "short_added"
	LOG_SHORT_UPDATED = "short_updated"
	LOG_SHORT_DELETED = "short_deleted"

	LOG_LIST_ADDED   = "list_added"
	LOG_LIST_UPDATED = "list_updated"
	LOG_LIST_DELETED = "list_deleted"

	LOG_LIST_LINK_ADDED   = "list_link_added"
	LOG_LIST_LINK_UPDATED = "list_link_updated"
	LOG_LIST_LINK_DELETED = "list_link_deleted"

	LOG_MEMBER_ADDED     = "member_added"
	LOG_MEMBER_CONFIRMED = "member_confirmed"
	LOG_MEMBER_REMOVED   = "member_removed"

	LOG_DOMAIN_ADDED   = "domain_added"
	LOG_DOMAIN_DELETED = "domain_deleted"

	LOG_QRCODE_ADDED   = "qrcode_added"
	LOG_QRCODE_DELETED = "qrcode_deleted"

	LOG_ORG_FOLLOW   = "org_follow"
	LOG_ORG_UNFOLLOW = "org_unfollow"
)

func RecordAuditLog(ctx context.Context, userID int,
	ipAddress, eventType, details string, metadata map[string]any) error {
	alog := auditlog.New(userID, ipAddress, eventType, details)
	if metadata != nil {
		for k, v := range metadata {
			alog.AddMetadata(k, v)
		}
	}
	return alog.Store(ctx)
}
diff --git a/models/models.go b/models/models.go
index 5dff73d..6728306 100644
--- a/models/models.go
+++ b/models/models.go
@@ -19,11 +19,6 @@ const (
	PRIVATERSSFEEDCONF         = 300
)

// Event types for audit log entries
const (
	LOG_SHORT_ADDED = "short_added"
)

type AnalyticsData map[string]int

// User ...
-- 
2.47.2
Applied.

To git@git.code.netlandish.com:~netlandish/links
   097aac3..44ef6e8  master -> master